A file-based task manager
1//! High-level workspace API. Orchestrates [`object`], [`namespace`], and
2//! [`queue`] to back the CLI commands.
3//!
4//! Per-clone state lives under `<git-dir>/tsk/` (not tracked, not pushed).
5//! Two files select the active namespace and queue (defaults: `tsk` / `tsk`):
6//! `<git-dir>/tsk/namespace` and `<git-dir>/tsk/queue`.
7
8use crate::errors::{Error, Result};
9use crate::object::{self, StableId, Task as TaskObj};
10use crate::patch;
11use crate::{merge, namespace, properties, queue};
12use git2::Repository;
13use std::collections::BTreeMap;
14use std::fmt::Display;
15use std::path::PathBuf;
16use std::str::FromStr;
17use std::sync::OnceLock;
18
19/// Process-wide override for the active queue, set once by the CLI's
20/// `-q/--queue` flag. When `Some`, `Workspace::queue()` returns this
21/// value instead of reading `<git-dir>/tsk/queue`.
22static QUEUE_OVERRIDE: OnceLock<Option<String>> = OnceLock::new();
23
24pub fn set_queue_override(q: Option<String>) {
25 let _ = QUEUE_OVERRIDE.set(q);
26}
27
28#[derive(Debug)]
29pub struct ImportOutcome {
30 pub stable: StableId,
31 pub commits_imported: usize,
32 pub bound_human: Option<u32>,
33}
34
35const NAMESPACE_FILE: &str = "namespace";
36const QUEUE_FILE: &str = "queue";
37const REMOTE_FILE: &str = "remote";
38pub const DEFAULT_REMOTE: &str = "origin";
39/// Auto-managed property holding the task's lifecycle state. Set to
40/// `STATUS_OPEN` on creation and flipped to `STATUS_DONE` by [`Workspace::drop`].
41pub const STATUS_KEY: &str = "status";
42pub const STATUS_OPEN: &str = "open";
43pub const STATUS_DONE: &str = "done";
44/// User-local state lives under `<git-dir>/<STATE_DIR>/` so it isn't tracked
45/// by the enclosing repo (the `.git/` directory is by definition not in the
46/// working tree). Each clone gets its own active namespace + queue.
47const STATE_DIR: &str = "tsk";
48
49/// A human-readable task identifier (`tsk-N`). Always namespace-scoped: the
50/// integer N has no meaning without the namespace it was minted in.
51#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
52pub struct Id(pub u32);
53
54impl FromStr for Id {
55 type Err = Error;
56 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
57 let upper = s.to_uppercase();
58 let s = upper
59 .trim()
60 .strip_prefix("TSK-")
61 .ok_or(Self::Err::Parse(format!("expected tsk- prefix. Got {s}")))?;
62 Ok(Self(s.parse()?))
63 }
64}
65
66impl Display for Id {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 write!(f, "tsk-{}", self.0)
69 }
70}
71
72impl From<u32> for Id {
73 fn from(v: u32) -> Self {
74 Id(v)
75 }
76}
77
78#[derive(Clone)]
79pub enum TaskIdentifier {
80 Id(Id),
81 /// Index into the active queue's stack (0 = top).
82 Relative(u32),
83}
84
85impl From<Id> for TaskIdentifier {
86 fn from(v: Id) -> Self {
87 TaskIdentifier::Id(v)
88 }
89}
90
91/// One row of a queue listing.
92pub struct StackEntry {
93 pub id: Id,
94 pub stable: StableId,
95 pub title: String,
96}
97
98/// User-facing task: human id (in active namespace) + content + properties.
99/// Each property holds zero or more text values.
100#[derive(Debug)]
101pub struct Task {
102 #[allow(dead_code)] // exposed for callers; constructed by workspace
103 pub id: Id,
104 #[allow(dead_code)] // exposed for callers; constructed by workspace
105 pub stable: StableId,
106 pub title: String,
107 pub body: String,
108 pub attributes: BTreeMap<String, Vec<String>>,
109}
110
111impl Display for Task {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 write!(f, "{}\n\n{}", self.title, self.body)
114 }
115}
116
117/// One commit on a tsk ref's history.
118pub struct LogCommit {
119 pub oid: String,
120 pub timestamp: i64,
121 pub author: String,
122 pub summary: String,
123}
124
125/// One pending inbox item in the active queue.
126pub struct InboxItem {
127 pub key: String,
128 pub source_queue: String,
129 #[allow(dead_code)] // exposed for callers; constructed by workspace
130 pub stable: StableId,
131 pub title: String,
132}
133
134pub struct Workspace {
135 /// The user-local state directory: `<git-dir>/tsk/`. Holds the
136 /// `namespace` and `queue` selectors — both per-clone, not pushed.
137 pub path: PathBuf,
138 /// The enclosing git repo's `.git` directory.
139 pub git_dir: PathBuf,
140}
141
142impl Workspace {
143 /// Bootstrap user-local state in `<git-dir>/tsk/`. Idempotent: existing
144 /// state files are left alone so re-init doesn't reset the active
145 /// namespace/queue. Errors if `path` isn't inside a git repository.
146 pub fn init(path: PathBuf) -> Result<()> {
147 let git_dir = find_git_dir(&path)
148 .ok_or_else(|| Error::Parse("tsk requires an enclosing git repository".into()))?;
149 let state_dir = git_dir.join(STATE_DIR);
150 std::fs::create_dir_all(&state_dir)?;
151 let ns = state_dir.join(NAMESPACE_FILE);
152 if !ns.exists() {
153 std::fs::write(&ns, namespace::DEFAULT_NS.as_bytes())?;
154 }
155 let q = state_dir.join(QUEUE_FILE);
156 if !q.exists() {
157 std::fs::write(&q, queue::DEFAULT_QUEUE.as_bytes())?;
158 }
159 Ok(())
160 }
161
162 pub fn from_path(path: PathBuf) -> Result<Self> {
163 let git_dir = find_git_dir(&path).ok_or(Error::Uninitialized)?;
164 let state_dir = git_dir.join(STATE_DIR);
165 if !state_dir.exists() {
166 // Auto-bootstrap so `git tsk <anything>` works without an
167 // explicit init step.
168 Self::init(path)?;
169 }
170 Ok(Self {
171 path: state_dir,
172 git_dir,
173 })
174 }
175
176 fn repo(&self) -> Result<Repository> {
177 Ok(Repository::open(&self.git_dir)?)
178 }
179
180 fn read_selector(&self, file: &str, default: &str) -> String {
181 std::fs::read_to_string(self.path.join(file))
182 .ok()
183 .map(|s| s.trim().to_string())
184 .filter(|s| !s.is_empty())
185 .unwrap_or_else(|| default.to_string())
186 }
187
188 pub fn namespace(&self) -> String {
189 self.read_selector(NAMESPACE_FILE, namespace::DEFAULT_NS)
190 }
191
192 pub fn queue(&self) -> String {
193 if let Some(Some(q)) = QUEUE_OVERRIDE.get() {
194 return q.clone();
195 }
196 self.read_selector(QUEUE_FILE, queue::DEFAULT_QUEUE)
197 }
198
199 pub fn switch_namespace(&self, name: &str) -> Result<()> {
200 namespace::validate_name(name)?;
201 std::fs::write(self.path.join(NAMESPACE_FILE), name.as_bytes())?;
202 Ok(())
203 }
204
205 pub fn switch_queue(&self, name: &str) -> Result<()> {
206 queue::validate_name(name)?;
207 std::fs::write(self.path.join(QUEUE_FILE), name.as_bytes())?;
208 Ok(())
209 }
210
211 /// Persist a clone-local default remote so `tsk git-push` /
212 /// `tsk git-pull` (and the auto-push paths) target it without an
213 /// explicit `<remote>` arg.
214 pub fn default_remote(&self) -> String {
215 self.read_selector(REMOTE_FILE, DEFAULT_REMOTE)
216 }
217
218 /// Persist `name` as the default remote. Errors if `name` isn't a
219 /// configured git remote — tsk only ever uses remotes the host repo
220 /// already knows about.
221 pub fn set_default_remote(&self, name: &str) -> Result<()> {
222 let known = self.git_remotes()?;
223 if !known.iter().any(|r| r == name) {
224 return Err(Error::Parse(format!(
225 "no such git remote '{name}'; configured: {}",
226 if known.is_empty() {
227 "<none>".into()
228 } else {
229 known.join(", ")
230 }
231 )));
232 }
233 std::fs::write(self.path.join(REMOTE_FILE), name.as_bytes())?;
234 Ok(())
235 }
236
237 pub fn git_remotes(&self) -> Result<Vec<String>> {
238 let out = self.git().arg("remote").output()?;
239 if !out.status.success() {
240 return Err(Error::Parse("git remote failed".into()));
241 }
242 Ok(String::from_utf8_lossy(&out.stdout)
243 .lines()
244 .map(|s| s.trim().to_string())
245 .filter(|s| !s.is_empty())
246 .collect())
247 }
248
249 pub fn list_namespaces(&self) -> Result<Vec<String>> {
250 namespace::list_names(&self.repo()?)
251 }
252
253 pub fn list_queues(&self) -> Result<Vec<String>> {
254 queue::list_names(&self.repo()?)
255 }
256
257 pub fn create_queue(&self, name: &str, can_pull: Option<bool>) -> Result<()> {
258 queue::validate_name(name)?;
259 let repo = self.repo()?;
260 let mut q = queue::read(&repo, name)?;
261 if let Some(cp) = can_pull {
262 q.can_pull = cp;
263 }
264 queue::write(&repo, name, &q, "create queue")
265 }
266
267 fn resolve(&self, identifier: TaskIdentifier) -> Result<(Id, StableId)> {
268 match identifier {
269 TaskIdentifier::Id(id) => {
270 let stable = namespace::lookup(&self.repo()?, &self.namespace(), id.0)?
271 .ok_or_else(|| Error::Parse(format!("Task {id} not found in namespace")))?;
272 Ok((id, stable))
273 }
274 TaskIdentifier::Relative(r) => {
275 let stack = self.read_stack()?;
276 let entry = stack.into_iter().nth(r as usize).ok_or(Error::NoTasks)?;
277 Ok((entry.id, entry.stable))
278 }
279 }
280 }
281
282 fn make_task(id: Id, stable: StableId, obj: TaskObj) -> Task {
283 Task {
284 title: obj.title().to_string(),
285 body: obj.body().to_string(),
286 attributes: obj.properties,
287 id,
288 stable,
289 }
290 }
291
292 fn read_task_obj(repo: &Repository, stable: &StableId) -> Result<TaskObj> {
293 object::read(repo, stable)?
294 .ok_or_else(|| Error::Parse(format!("task {stable} content missing")))
295 }
296
297 fn title_for(repo: &Repository, stable: &StableId) -> Result<String> {
298 Ok(object::read(repo, stable)?
299 .map(|t| t.title().to_string())
300 .unwrap_or_default())
301 }
302
303 /// Create a task — or, when the content matches an existing task,
304 /// reopen / re-bind it instead of clobbering.
305 ///
306 /// Stable id is content-addressed (SHA-1 of the content blob), so two
307 /// `new_task` calls with the same body produce the same stable id and
308 /// would collide on the task ref. Branches:
309 ///
310 /// - ref doesn't exist → fresh create.
311 /// - ref exists, bound in active namespace, status=done → reopen
312 /// (flip status back to `open`, return the existing human id).
313 /// - ref exists, bound in active namespace, status=open → idempotent;
314 /// return the existing human id without touching the task tree.
315 /// - ref exists, bound in another namespace but not the active one →
316 /// error with a hint to use `tsk share` or `tsk reopen -T`.
317 /// - ref exists, unbound everywhere → bind it in the active namespace.
318 pub fn new_task(&self, title: String, body: String) -> Result<Task> {
319 let repo = self.repo()?;
320 let content = if body.is_empty() {
321 title.trim().to_string()
322 } else {
323 format!("{}\n\n{}", title.trim(), body.trim())
324 };
325 // Compute the stable id without writing anything.
326 let content_oid = repo.blob(content.as_bytes())?;
327 let stable = StableId(content_oid.to_string());
328 let active_ns = self.namespace();
329
330 if repo.find_reference(&stable.refname()).is_err() {
331 let mut obj = TaskObj::new(content);
332 obj.properties
333 .insert(STATUS_KEY.into(), vec![STATUS_OPEN.into()]);
334 let stable = object::create(&repo, &obj, "create")?;
335 properties::reindex_task(&repo, &stable, &obj.properties)?;
336 let human =
337 namespace::assign_id(&repo, &active_ns, stable.clone(), "assign-id")?;
338 return Ok(Self::make_task(Id(human), stable, obj));
339 }
340
341 // Ref already exists. Decide between reopen / idempotent / bind / error.
342 if let Some(human) = namespace::human_for(&repo, &active_ns, &stable)? {
343 let mut obj = Self::read_task_obj(&repo, &stable)?;
344 let is_done = obj
345 .properties
346 .get(STATUS_KEY)
347 .is_some_and(|v| v.iter().any(|s| s == STATUS_DONE));
348 if is_done {
349 obj.properties
350 .insert(STATUS_KEY.into(), vec![STATUS_OPEN.into()]);
351 object::update(&repo, &stable, &obj, "reopen")?;
352 properties::reindex_task(&repo, &stable, &obj.properties)?;
353 }
354 return Ok(Self::make_task(Id(human), stable, obj));
355 }
356
357 // Not bound here. Refuse if it lives in another namespace; otherwise bind.
358 let mut elsewhere = Vec::new();
359 for ns_name in namespace::list_names(&repo)? {
360 if ns_name == active_ns {
361 continue;
362 }
363 if let Some(h) = namespace::human_for(&repo, &ns_name, &stable)? {
364 elsewhere.push(format!("{ns_name}-{h}"));
365 }
366 }
367 if !elsewhere.is_empty() {
368 return Err(Error::Parse(format!(
369 "task with this content is already bound at {} — use `tsk share {active_ns} -T <id>` or `tsk reopen -T <id>` to bind it into '{active_ns}'",
370 elsewhere.join(", ")
371 )));
372 }
373 let obj = Self::read_task_obj(&repo, &stable)?;
374 let human = namespace::assign_id(&repo, &active_ns, stable.clone(), "assign-id")?;
375 Ok(Self::make_task(Id(human), stable, obj))
376 }
377
378 pub fn task(&self, identifier: TaskIdentifier) -> Result<Task> {
379 let (id, stable) = self.resolve(identifier)?;
380 let obj = Self::read_task_obj(&self.repo()?, &stable)?;
381 Ok(Self::make_task(id, stable, obj))
382 }
383
384 /// Persist any in-memory edits to a task. Returns `true` when the
385 /// underlying `object::update` actually wrote a new commit (i.e. the
386 /// resulting tree differs from the current tip); `false` on a no-op.
387 pub fn save_task(&self, task: &Task) -> Result<bool> {
388 let repo = self.repo()?;
389 let content = if task.body.is_empty() {
390 task.title.trim().to_string()
391 } else {
392 format!("{}\n\n{}", task.title.trim(), task.body.trim())
393 };
394 let task_obj = TaskObj {
395 content,
396 properties: task.attributes.clone(),
397 };
398 let wrote = object::update(&repo, &task.stable, &task_obj, "edit")?;
399 properties::reindex_task(&repo, &task.stable, &task.attributes)?;
400 Ok(wrote)
401 }
402
403 /// Append a value to a property on a task. If the value is already
404 /// present, this is a no-op. Persists both the task tree and the index.
405 pub fn add_property_value(
406 &self,
407 identifier: TaskIdentifier,
408 key: &str,
409 value: &str,
410 ) -> Result<()> {
411 let mut task = self.task(identifier)?;
412 let entry = task.attributes.entry(key.to_string()).or_default();
413 if !entry.iter().any(|v| v == value) {
414 entry.push(value.to_string());
415 }
416 self.save_task(&task)?;
417 Ok(())
418 }
419
420 /// Replace the entire value list for a property.
421 pub fn set_property(
422 &self,
423 identifier: TaskIdentifier,
424 key: &str,
425 values: Vec<String>,
426 ) -> Result<()> {
427 let mut task = self.task(identifier)?;
428 if values.is_empty() {
429 task.attributes.remove(key);
430 } else {
431 task.attributes.insert(key.to_string(), values);
432 }
433 self.save_task(&task)?;
434 Ok(())
435 }
436
437 /// Remove a single value from a property, or the whole property if
438 /// `value` is None.
439 pub fn unset_property(
440 &self,
441 identifier: TaskIdentifier,
442 key: &str,
443 value: Option<&str>,
444 ) -> Result<()> {
445 let mut task = self.task(identifier)?;
446 match value {
447 None => {
448 task.attributes.remove(key);
449 }
450 Some(v) => {
451 if let Some(entry) = task.attributes.get_mut(key) {
452 entry.retain(|x| x != v);
453 if entry.is_empty() {
454 task.attributes.remove(key);
455 }
456 }
457 }
458 }
459 self.save_task(&task)?;
460 Ok(())
461 }
462
463 pub fn property_keys(&self) -> Result<Vec<String>> {
464 properties::list_keys(&self.repo()?)
465 }
466
467 pub fn property_values(&self, key: &str) -> Result<Vec<String>> {
468 properties::values_for(&self.repo()?, key)
469 }
470
471 /// Find tasks (by human id, scoped to active namespace) that have
472 /// `key` set; if `value` is supplied, restricts to entries containing
473 /// that value.
474 pub fn find_by_property(
475 &self,
476 key: &str,
477 value: Option<&str>,
478 ) -> Result<Vec<(Id, StableId, String)>> {
479 let repo = self.repo()?;
480 let by_stable = ns_reverse(&namespace::read(&repo, &self.namespace())?);
481 let mut out = Vec::new();
482 for stable in properties::find(&repo, key, value)? {
483 let Some(&human) = by_stable.get(&stable) else { continue };
484 out.push((Id(human), stable.clone(), Self::title_for(&repo, &stable)?));
485 }
486 Ok(out)
487 }
488
489 pub fn push_task(&self, task: Task) -> Result<()> {
490 queue::push_top(&self.repo()?, &self.queue(), task.stable, "push")
491 }
492
493 pub fn append_task(&self, task: Task) -> Result<()> {
494 queue::push_bottom(&self.repo()?, &self.queue(), task.stable, "append")
495 }
496
497 pub fn read_stack(&self) -> Result<Vec<StackEntry>> {
498 let repo = self.repo()?;
499 let by_stable = ns_reverse(&namespace::read(&repo, &self.namespace())?);
500 let mut out = Vec::new();
501 for stable in queue::read(&repo, &self.queue())?.index {
502 // Skip tasks not visible in the active namespace (different ns owns them).
503 let Some(&human) = by_stable.get(&stable) else { continue };
504 let title = Self::title_for(&repo, &stable)?;
505 out.push(StackEntry { id: Id(human), stable, title });
506 }
507 Ok(out)
508 }
509
510 /// Every (human id, stable id, title) bound in the given namespace,
511 /// sorted by human id ascending. Independent of any queue.
512 pub fn list_namespace_tasks(&self, name: &str) -> Result<Vec<StackEntry>> {
513 let repo = self.repo()?;
514 let mut out = Vec::new();
515 for (human, stable) in namespace::read(&repo, name)?.mapping {
516 let title = Self::title_for(&repo, &stable)?;
517 out.push(StackEntry { id: Id(human), stable, title });
518 }
519 Ok(out)
520 }
521
522 /// One commit on a tsk ref (task / namespace / queue).
523 pub fn log_ref(&self, refname: &str) -> Result<Vec<LogCommit>> {
524 let repo = self.repo()?;
525 let Ok(r) = repo.find_reference(refname) else {
526 return Err(Error::Parse(format!("ref {refname} not found")));
527 };
528 let Some(target) = r.target() else {
529 return Ok(Vec::new());
530 };
531 let mut out = Vec::new();
532 let mut current = repo.find_commit(target).ok();
533 while let Some(c) = current {
534 out.push(LogCommit {
535 oid: c.id().to_string(),
536 timestamp: c.time().seconds(),
537 author: format!(
538 "{} <{}>",
539 c.author().name().unwrap_or(""),
540 c.author().email().unwrap_or("")
541 ),
542 summary: c.summary().unwrap_or("").to_string(),
543 });
544 current = c.parent(0).ok();
545 }
546 Ok(out)
547 }
548
549 /// Export a task as an mbox-format patch series. With `bind=true`, the
550 /// root entry carries the active namespace's human id so the recipient
551 /// can opt in to mirroring the binding on import.
552 #[allow(dead_code)] // single-task wrapper, kept for callers that don't care about batch
553 pub fn export_task(&self, identifier: TaskIdentifier, bind: bool) -> Result<String> {
554 self.export_tasks(&[identifier], bind)
555 }
556
557 /// Export multiple tasks as a single concatenated mbox stream.
558 /// Each task's full commit chain is emitted in order; the importer
559 /// groups them back by stable id.
560 pub fn export_tasks(
561 &self,
562 identifiers: &[TaskIdentifier],
563 bind: bool,
564 ) -> Result<String> {
565 let repo = self.repo()?;
566 let mut out = String::new();
567 for ident in identifiers {
568 let (id, stable) = self.resolve(ident.clone())?;
569 let opts = patch::ExportOpts {
570 bind: if bind {
571 Some((self.namespace(), id.0))
572 } else {
573 None
574 },
575 };
576 out.push_str(&patch::export_task(&repo, &stable, &opts)?);
577 }
578 Ok(out)
579 }
580
581 /// Import a task from an mbox patch series produced by `export_task`.
582 /// On `bind=true`, also bind the imported stable id into the active
583 /// namespace (reusing the existing human id if already bound).
584 pub fn import_task(&self, mbox: &str, bind: bool) -> Result<Vec<ImportOutcome>> {
585 let repo = self.repo()?;
586 let results = patch::import_mbox(&repo, mbox)?;
587 let mut out = Vec::with_capacity(results.len());
588 for res in results {
589 let bound_human = if bind {
590 Some(namespace::ensure_bound(
591 &repo,
592 &self.namespace(),
593 res.stable.clone(),
594 "import-bind",
595 )?)
596 } else {
597 None
598 };
599 out.push(ImportOutcome {
600 stable: res.stable,
601 commits_imported: res.commits_imported,
602 bound_human,
603 });
604 }
605 Ok(out)
606 }
607
608 /// History of edits to a single task.
609 pub fn log_task(&self, identifier: TaskIdentifier) -> Result<Vec<LogCommit>> {
610 let (_, stable) = self.resolve(identifier)?;
611 self.log_ref(&stable.refname())
612 }
613
614 /// History of edits to a namespace's tree (id assignments, drops, shares).
615 pub fn log_namespace(&self, name: &str) -> Result<Vec<LogCommit>> {
616 self.log_ref(&namespace::refname(name))
617 }
618
619 /// History of edits to a queue's tree (pushes, drops, inbox moves).
620 pub fn log_queue(&self, name: &str) -> Result<Vec<LogCommit>> {
621 self.log_ref(&queue::refname(name))
622 }
623
624 /// Set `status=open` on every task in the active namespace that has no
625 /// status yet. Skips tasks already marked done. Returns the number of
626 /// tasks updated. One-shot migration for tasks created before
627 /// auto-status existed.
628 pub fn backfill_status(&self) -> Result<usize> {
629 let repo = self.repo()?;
630 let ns = namespace::read(&repo, &self.namespace())?;
631 let mut updated = 0usize;
632 for (human, _stable) in ns.mapping.iter() {
633 let mut task = self.task(TaskIdentifier::Id(Id(*human)))?;
634 if task.attributes.contains_key(STATUS_KEY) {
635 continue;
636 }
637 task.attributes
638 .insert(STATUS_KEY.into(), vec![STATUS_OPEN.into()]);
639 self.save_task(&task)?;
640 updated += 1;
641 }
642 Ok(updated)
643 }
644
645 /// Re-save every task in the active namespace whose property blobs are
646 /// in the legacy line-split encoding, rewriting them as size-prefixed.
647 /// Returns the number of tasks rewritten. Idempotent — `save_task`
648 /// no-ops on already-migrated tasks.
649 pub fn migrate_property_encoding(&self) -> Result<usize> {
650 let ns = namespace::read(&self.repo()?, &self.namespace())?;
651 let mut rewritten = 0;
652 for human in ns.mapping.keys() {
653 let task = self.task(TaskIdentifier::Id(Id(*human)))?;
654 if self.save_task(&task)? {
655 rewritten += 1;
656 }
657 }
658 Ok(rewritten)
659 }
660
661 /// Prune empty / orphan refs under `refs/tsk/*` and recover from
662 /// partial multi-ref writes. Returns
663 /// `(queues_dropped, prop_orphans_dropped, ghost_bindings_dropped, orphan_queue_entries_dropped)`.
664 ///
665 /// Recovers:
666 /// - empty queues (no index, no inbox) other than the default `tsk`
667 /// queue, which always exists by convention;
668 /// - property index entries pointing at task refs that no longer
669 /// resolve (the index ref itself is auto-deleted by `properties::set`
670 /// when its last entry goes);
671 /// - ghost namespace bindings (`human → stable` where stable's task
672 /// ref doesn't resolve) — left behind if a crash hit between
673 /// `object::create` and `namespace::assign_id` and the task object
674 /// was later GC'd, or if a remote namespace ref was force-pushed
675 /// ahead of its task refs;
676 /// - queue index entries pointing at missing task refs (same root
677 /// cause); also covered by `tsk clean` for the active queue.
678 ///
679 /// Task object refs are left alone — task history is preserved
680 /// intentionally — and namespace `next` counters are valid even when
681 /// no live binding uses the latest id.
682 pub fn gc_refs(&self) -> Result<(usize, usize, usize, usize)> {
683 let repo = self.repo()?;
684 let task_exists = |s: &StableId| repo.find_reference(&s.refname()).is_ok();
685 let mut queues_pruned = 0;
686 let mut prop_orphans = 0;
687 let mut ghost_bindings = 0;
688 let mut orphan_queue_entries = 0;
689
690 // Empty queues + orphan queue index entries.
691 for name in queue::list_names(&repo)? {
692 let mut q = queue::read(&repo, &name)?;
693 let before = q.index.len();
694 q.index.retain(&task_exists);
695 if q.index.len() != before {
696 let removed = before - q.index.len();
697 orphan_queue_entries += removed;
698 queue::write(&repo, &name, &q, "gc-orphan-queue")?;
699 }
700 if name != queue::DEFAULT_QUEUE && q.index.is_empty() && q.inbox.is_empty() {
701 if let Ok(mut r) = repo.find_reference(&queue::refname(&name)) {
702 r.delete()?;
703 queues_pruned += 1;
704 }
705 }
706 }
707
708 // Orphan property index entries.
709 for key in properties::list_keys(&repo)? {
710 for (stable, _vals) in properties::read(&repo, &key)? {
711 if !task_exists(&stable) {
712 properties::set(&repo, &key, &stable, &[], "gc-orphan")?;
713 prop_orphans += 1;
714 }
715 }
716 }
717
718 // Ghost namespace bindings (human → stable with no task ref).
719 for ns_name in namespace::list_names(&repo)? {
720 let mut ns = namespace::read(&repo, &ns_name)?;
721 let before = ns.mapping.len();
722 ns.mapping.retain(|_, s| task_exists(s));
723 if ns.mapping.len() != before {
724 ghost_bindings += before - ns.mapping.len();
725 namespace::write(&repo, &ns_name, &ns, "gc-ghost-bindings")?;
726 }
727 }
728
729 Ok((queues_pruned, prop_orphans, ghost_bindings, orphan_queue_entries))
730 }
731
732 /// Flip a task back to `status=open` and push it to the top of the
733 /// active queue. Idempotent — already-open tasks are unchanged on
734 /// disk; the queue push deduplicates on the existing entry.
735 pub fn reopen(&self, identifier: TaskIdentifier) -> Result<Id> {
736 let mut task = self.task(identifier)?;
737 task.attributes
738 .insert(STATUS_KEY.into(), vec![STATUS_OPEN.into()]);
739 self.save_task(&task)?;
740 queue::push_top(&self.repo()?, &self.queue(), task.stable, "reopen")?;
741 Ok(task.id)
742 }
743
744 /// Drop a task from the active queue and mark it `status=done`. The
745 /// namespace binding is kept so the task remains addressable by its
746 /// human id (and discoverable via `tsk prop find status done`); the
747 /// task object's commit history is preserved either way.
748 pub fn drop(&self, identifier: TaskIdentifier) -> Result<Option<Id>> {
749 let (id, stable) = self.resolve(identifier)?;
750 let repo = self.repo()?;
751 queue::remove(&repo, &self.queue(), &stable, "drop")?;
752 // Flip status=done in the task's tree + index.
753 let mut task = self.task(TaskIdentifier::Id(id))?;
754 task.attributes
755 .insert(STATUS_KEY.into(), vec![STATUS_DONE.into()]);
756 self.save_task(&task)?;
757 Ok(Some(id))
758 }
759
760 fn mutate_index<F: FnOnce(&mut Vec<StableId>)>(&self, f: F, msg: &str) -> Result<()> {
761 let repo = self.repo()?;
762 let mut q = queue::read(&repo, &self.queue())?;
763 f(&mut q.index);
764 queue::write(&repo, &self.queue(), &q, msg)
765 }
766
767 pub fn swap_top(&self) -> Result<()> {
768 self.mutate_index(
769 |idx| {
770 if idx.len() >= 2 {
771 idx.swap(0, 1);
772 }
773 },
774 "swap",
775 )
776 }
777
778 fn rotate_top3(&self, third_to_top: bool) -> Result<()> {
779 self.mutate_index(
780 |idx| {
781 if idx.len() >= 3 {
782 if third_to_top {
783 let c = idx.remove(2);
784 idx.insert(0, c);
785 } else {
786 let a = idx.remove(0);
787 idx.insert(2, a);
788 }
789 }
790 },
791 "rotate",
792 )
793 }
794
795 pub fn rot(&self) -> Result<()> {
796 self.rotate_top3(true)
797 }
798
799 pub fn tor(&self) -> Result<()> {
800 self.rotate_top3(false)
801 }
802
803 fn move_in_index(&self, identifier: TaskIdentifier, to_front: bool) -> Result<()> {
804 let (_, stable) = self.resolve(identifier)?;
805 self.mutate_index(
806 |idx| {
807 idx.retain(|s| s != &stable);
808 if to_front {
809 idx.insert(0, stable);
810 } else {
811 idx.push(stable);
812 }
813 },
814 if to_front { "prioritize" } else { "deprioritize" },
815 )
816 }
817
818 pub fn prioritize(&self, identifier: TaskIdentifier) -> Result<()> {
819 self.move_in_index(identifier, true)
820 }
821
822 pub fn deprioritize(&self, identifier: TaskIdentifier) -> Result<()> {
823 self.move_in_index(identifier, false)
824 }
825
826 /// Drop entries from the active queue's index whose stable ids no longer
827 /// resolve to a task object.
828 pub fn clean(&self) -> Result<()> {
829 let repo = self.repo()?;
830 let mut q = queue::read(&repo, &self.queue())?;
831 let before = q.index.len();
832 q.index.retain(|s| {
833 repo.find_reference(&s.refname())
834 .ok()
835 .and_then(|r| r.target())
836 .is_some()
837 });
838 if q.index.len() != before {
839 queue::write(&repo, &self.queue(), &q, "clean")?;
840 }
841 Ok(())
842 }
843
844 /// Share a task into another namespace by binding the same stable id to
845 /// the next human id in `target_ns`.
846 pub fn share(&self, identifier: TaskIdentifier, target_ns: &str) -> Result<u32> {
847 let cur = self.namespace();
848 if target_ns == cur {
849 return Err(Error::Parse(
850 "Refusing to share a task into its own namespace".into(),
851 ));
852 }
853 namespace::validate_name(target_ns)?;
854 let (_, stable) = self.resolve(identifier)?;
855 let repo = self.repo()?;
856 namespace::assign_id(&repo, target_ns, stable, "share")
857 }
858
859 /// Move a task from the active queue's index into `target_queue`'s inbox.
860 pub fn assign_to_queue(
861 &self,
862 identifier: TaskIdentifier,
863 target_queue: &str,
864 ) -> Result<(String, StableId)> {
865 let cur = self.queue();
866 if target_queue == cur {
867 return Err(Error::Parse(
868 "Refusing to assign a task to its own queue".into(),
869 ));
870 }
871 queue::validate_name(target_queue)?;
872 let (id, stable) = self.resolve(identifier)?;
873 let repo = self.repo()?;
874 let key = queue::inbox_key(&cur, id.0);
875 queue::add_to_inbox(&repo, target_queue, key.clone(), stable.clone(), "assign")?;
876 queue::remove(&repo, &cur, &stable, "assigned-out")?;
877 Ok((key, stable))
878 }
879
880 pub fn list_inbox(&self) -> Result<Vec<InboxItem>> {
881 let repo = self.repo()?;
882 let mut out = Vec::new();
883 for (key, stable) in queue::read(&repo, &self.queue())?.inbox {
884 let source_queue = key
885 .rsplit_once('-')
886 .map(|(s, _)| s.to_string())
887 .unwrap_or_else(|| key.clone());
888 let title = Self::title_for(&repo, &stable)?;
889 out.push(InboxItem { key, source_queue, stable, title });
890 }
891 Ok(out)
892 }
893
894 /// Accept an inbox item: bind to a human id in the active namespace
895 /// (if not already), and push onto the top of the active queue.
896 pub fn accept_inbox(&self, key: &str) -> Result<Id> {
897 let repo = self.repo()?;
898 let stable = queue::take_from_inbox(&repo, &self.queue(), key, "accept")?
899 .ok_or_else(|| Error::Parse(format!("Inbox item '{key}' not found")))?;
900 let human =
901 namespace::ensure_bound(&repo, &self.namespace(), stable.clone(), "accept-bind")?;
902 queue::push_top(&repo, &self.queue(), stable, "accept-push")?;
903 Ok(Id(human))
904 }
905
906 /// Reject an inbox item: remove it from the active queue's inbox and
907 /// bounce it back to the sender's inbox so they see the return. The
908 /// source queue is recovered from the key (`<src>-<seq>`); the return
909 /// key is `<active>-<seq>` so each round-trip is uniquely identified.
910 pub fn reject_inbox(&self, key: &str) -> Result<()> {
911 let repo = self.repo()?;
912 let stable = queue::take_from_inbox(&repo, &self.queue(), key, "reject")?
913 .ok_or_else(|| Error::Parse(format!("Inbox item '{key}' not found")))?;
914 if let Some((src, seq)) = key.rsplit_once('-') {
915 let cur = self.queue();
916 if src != cur {
917 let return_key = format!("{cur}-{seq}");
918 queue::add_to_inbox(&repo, src, return_key, stable, "reject-return")?;
919 }
920 }
921 Ok(())
922 }
923
924 /// Pull a task from a foreign queue's index into the active queue's
925 /// index. Only allowed if the source queue's `can_pull` is true.
926 pub fn pull_from_queue(&self, source_queue: &str, identifier: TaskIdentifier) -> Result<Id> {
927 let cur = self.queue();
928 if source_queue == cur {
929 return Err(Error::Parse("Source queue equals active queue".into()));
930 }
931 let repo = self.repo()?;
932 let src = queue::read(&repo, source_queue)?;
933 if !src.can_pull {
934 return Err(Error::Parse(format!(
935 "Queue '{source_queue}' has can-pull=false; refusing"
936 )));
937 }
938 let (_, stable) = self.resolve(identifier)?;
939 if !src.index.iter().any(|s| s == &stable) {
940 return Err(Error::Parse(format!(
941 "Task not present in queue '{source_queue}'"
942 )));
943 }
944 queue::remove(&repo, source_queue, &stable, "pulled-out")?;
945 queue::push_top(&repo, &cur, stable.clone(), "pull")?;
946 let human = namespace::ensure_bound(&repo, &self.namespace(), stable, "pull-bind")?;
947 Ok(Id(human))
948 }
949
950 fn git(&self) -> std::process::Command {
951 let mut c = std::process::Command::new("git");
952 c.arg("--git-dir").arg(&self.git_dir);
953 c
954 }
955
956 pub fn configure_git_remote_refspecs(&self, remote: &str) -> Result<()> {
957 for (key, value) in [
958 (format!("remote.{remote}.push"), "refs/tsk/*:refs/tsk/*"),
959 (format!("remote.{remote}.fetch"), "+refs/tsk/*:refs/tsk/*"),
960 ] {
961 let existing = self.git().args(["config", "--get-all", &key]).output()?;
962 if String::from_utf8_lossy(&existing.stdout)
963 .lines()
964 .any(|l| l.trim() == value)
965 {
966 continue;
967 }
968 if !self
969 .git()
970 .args(["config", "--add", &key, value])
971 .status()?
972 .success()
973 {
974 return Err(Error::Parse("git config failed".into()));
975 }
976 }
977 Ok(())
978 }
979
980 pub fn git_push(&self, remote: &str) -> Result<()> {
981 if !self
982 .git()
983 .args(["push", remote, "refs/tsk/*:refs/tsk/*"])
984 .status()?
985 .success()
986 {
987 return Err(Error::Parse("git push failed".into()));
988 }
989 Ok(())
990 }
991
992 #[allow(dead_code)] // CLI calls git_pull_with_strategy directly; kept for API symmetry.
993 pub fn git_pull(&self, remote: &str) -> Result<()> {
994 self.git_pull_with_strategy(remote, merge::Strategy::default())?;
995 Ok(())
996 }
997
998 /// Push only the named refs to `remote`. Each ref is sent as its own
999 /// `<ref>:<ref>` refspec (no force) so the operation refuses
1000 /// non-fast-forward updates the same way `git_push` does.
1001 pub fn git_push_refs(&self, remote: &str, refs: &[String]) -> Result<()> {
1002 if refs.is_empty() {
1003 return Ok(());
1004 }
1005 let mut cmd = self.git();
1006 cmd.args(["push", remote]);
1007 for r in refs {
1008 cmd.arg(format!("{r}:{r}"));
1009 }
1010 if !cmd.status()?.success() {
1011 return Err(Error::Parse("git push failed".into()));
1012 }
1013 Ok(())
1014 }
1015
1016 /// Fetch only the named refs from `remote`, force-updating each. Used
1017 /// by paths (e.g. `tsk inbox`) that need a single ref refreshed without
1018 /// the wire cost of a full `git_pull`.
1019 pub fn git_fetch_refs(&self, remote: &str, refs: &[String]) -> Result<()> {
1020 if refs.is_empty() {
1021 return Ok(());
1022 }
1023 let mut cmd = self.git();
1024 cmd.args(["fetch", "--refmap=", remote]);
1025 for r in refs {
1026 cmd.arg(format!("+{r}:{r}"));
1027 }
1028 if !cmd.status()?.success() {
1029 return Err(Error::Parse("git fetch failed".into()));
1030 }
1031 Ok(())
1032 }
1033
1034 /// Refs to push after `assign_to_queue`: the target queue (gained an
1035 /// inbox entry), the task ref itself (so the receiver can read the
1036 /// body), and every property index that already references this task.
1037 pub fn refs_for_assign_out(
1038 &self,
1039 target_queue: &str,
1040 stable: &StableId,
1041 ) -> Result<Vec<String>> {
1042 let repo = self.repo()?;
1043 let mut refs = vec![queue::refname(target_queue), stable.refname()];
1044 for key in properties::list_keys(&repo)? {
1045 let entries = properties::read(&repo, &key)?;
1046 if entries.contains_key(stable) {
1047 refs.push(properties::refname(&key));
1048 }
1049 }
1050 Ok(refs)
1051 }
1052
1053 /// Refs to push after `accept_inbox`: the active queue (entry moved
1054 /// from inbox to index) and the active namespace (the receiver may have
1055 /// allocated a new human id binding the accepted task).
1056 pub fn refs_for_accept_inbox(&self) -> Vec<String> {
1057 vec![
1058 queue::refname(&self.queue()),
1059 namespace::refname(&self.namespace()),
1060 ]
1061 }
1062
1063 /// Refs to push after `reject_inbox`: the active queue (entry left the
1064 /// inbox) and the source queue (entry was bounced back into its inbox).
1065 pub fn refs_for_reject_inbox(&self, source_queue: &str) -> Vec<String> {
1066 vec![
1067 queue::refname(&self.queue()),
1068 queue::refname(source_queue),
1069 ]
1070 }
1071
1072 /// Refs to fetch before listing the inbox: just the active queue.
1073 pub fn refs_for_inbox_pull(&self) -> Vec<String> {
1074 vec![queue::refname(&self.queue())]
1075 }
1076
1077 /// Fetch into a non-clobbering shadow namespace, then reconcile each
1078 /// task ref under the chosen strategy (default `merge`). Non-task refs
1079 /// (namespaces/queues/property indices) still force-update from the
1080 /// remote — better merging for those is tracked separately.
1081 pub fn git_pull_with_strategy(
1082 &self,
1083 remote: &str,
1084 strategy: merge::Strategy,
1085 ) -> Result<merge::PullOutcome> {
1086 // `--refmap=` disables the remote's configured fetch refspec so our
1087 // explicit refspec is the *only* one applied; otherwise git also
1088 // performs the configured `+refs/tsk/*:refs/tsk/*` mapping and
1089 // clobbers local task refs before we get a chance to reconcile.
1090 let refspec = format!("+refs/tsk/*:{}{remote}/*", merge::FETCH_PREFIX);
1091 if !self
1092 .git()
1093 .args(["fetch", "--prune", "--refmap=", remote])
1094 .arg(&refspec)
1095 .status()?
1096 .success()
1097 {
1098 return Err(Error::Parse("git fetch failed".into()));
1099 }
1100 let repo = self.repo()?;
1101 let tasks = merge::reconcile_task_refs(&repo, remote, strategy)?;
1102 let namespaces = merge::reconcile_namespace_refs(&repo, remote)?;
1103 let queues = merge::reconcile_queue_refs(&repo, remote)?;
1104 merge::fast_forward_non_task_refs(&repo, remote)?;
1105 Ok(merge::PullOutcome { tasks, namespaces, queues })
1106 }
1107}
1108
1109/// `stable → human` reverse of a namespace mapping for O(log n) visibility checks.
1110fn ns_reverse(ns: &namespace::Namespace) -> BTreeMap<StableId, u32> {
1111 ns.mapping.iter().map(|(h, s)| (s.clone(), *h)).collect()
1112}
1113
1114pub fn find_git_dir(start: &std::path::Path) -> Option<PathBuf> {
1115 let mut cur = Some(start.to_path_buf());
1116 while let Some(p) = cur {
1117 let candidate = p.join(".git");
1118 if candidate.exists() {
1119 return Some(candidate);
1120 }
1121 cur = p.parent().map(|q| q.to_path_buf());
1122 }
1123 None
1124}
1125
1126#[cfg(test)]
1127mod test {
1128 use super::*;
1129
1130 fn run_git_init(p: &std::path::Path) {
1131 let s = std::process::Command::new("git")
1132 .args(["init", "-q", "-b", "main"])
1133 .current_dir(p)
1134 .status()
1135 .unwrap();
1136 assert!(s.success());
1137 let _ = std::process::Command::new("git")
1138 .args(["config", "user.name", "Test"])
1139 .current_dir(p)
1140 .status();
1141 let _ = std::process::Command::new("git")
1142 .args(["config", "user.email", "t@e"])
1143 .current_dir(p)
1144 .status();
1145 }
1146
1147 fn fresh_workspace() -> (tempfile::TempDir, Workspace) {
1148 let dir = tempfile::tempdir().unwrap();
1149 run_git_init(dir.path());
1150 Workspace::init(dir.path().to_path_buf()).unwrap();
1151 let ws = Workspace::from_path(dir.path().to_path_buf()).unwrap();
1152 (dir, ws)
1153 }
1154
1155 #[test]
1156 fn push_list_drop_round_trip() {
1157 let (_d, ws) = fresh_workspace();
1158 let t1 = ws.new_task("first".into(), "body 1".into()).unwrap();
1159 let id1 = t1.id;
1160 ws.push_task(t1).unwrap();
1161 let t2 = ws.new_task("second".into(), "".into()).unwrap();
1162 let id2 = t2.id;
1163 ws.push_task(t2).unwrap();
1164 let stack = ws.read_stack().unwrap();
1165 assert_eq!(
1166 stack.iter().map(|e| e.id).collect::<Vec<_>>(),
1167 vec![id2, id1]
1168 );
1169 let read = ws.task(TaskIdentifier::Id(id1)).unwrap();
1170 assert_eq!(read.title, "first");
1171 assert_eq!(read.body, "body 1");
1172 let dropped = ws.drop(TaskIdentifier::Id(id1)).unwrap();
1173 assert_eq!(dropped, Some(id1));
1174 let stack = ws.read_stack().unwrap();
1175 assert_eq!(stack.iter().map(|e| e.id).collect::<Vec<_>>(), vec![id2]);
1176 }
1177
1178 #[test]
1179 fn id_allocation_monotonic_across_drops() {
1180 let (_d, ws) = fresh_workspace();
1181 let t1 = ws.new_task("a".into(), "".into()).unwrap();
1182 let id1 = t1.id;
1183 ws.push_task(t1).unwrap();
1184 ws.drop(TaskIdentifier::Id(id1)).unwrap();
1185 let t2 = ws.new_task("b".into(), "".into()).unwrap();
1186 assert_eq!(t2.id.0, id1.0 + 1, "ids must not be reused after drop");
1187 }
1188
1189 #[test]
1190 fn edit_appends_history() {
1191 let (_d, ws) = fresh_workspace();
1192 let t = ws.new_task("v1".into(), "body".into()).unwrap();
1193 let id = t.id;
1194 let stable = t.stable.clone();
1195 ws.push_task(t).unwrap();
1196 let mut t = ws.task(TaskIdentifier::Id(id)).unwrap();
1197 t.title = "v2".into();
1198 ws.save_task(&t).unwrap();
1199 let read = ws.task(TaskIdentifier::Id(id)).unwrap();
1200 assert_eq!(read.title, "v2");
1201 assert_eq!(read.stable, stable, "stable id must not change on edit");
1202 let repo = ws.repo().unwrap();
1203 let head = repo
1204 .find_reference(&stable.refname())
1205 .unwrap()
1206 .target()
1207 .unwrap();
1208 let commit = repo.find_commit(head).unwrap();
1209 assert_eq!(commit.parent_count(), 1);
1210 }
1211
1212 #[test]
1213 fn share_to_other_namespace() {
1214 let (_d, ws) = fresh_workspace();
1215 let t = ws.new_task("shared".into(), "".into()).unwrap();
1216 let id_in_tsk = t.id;
1217 let stable = t.stable.clone();
1218 ws.push_task(t).unwrap();
1219 let h = ws.share(TaskIdentifier::Id(id_in_tsk), "alpha").unwrap();
1220 ws.switch_namespace("alpha").unwrap();
1221 let task_in_alpha = ws.task(TaskIdentifier::Id(Id(h))).unwrap();
1222 assert_eq!(task_in_alpha.stable, stable);
1223 assert_eq!(task_in_alpha.title, "shared");
1224 }
1225
1226 #[test]
1227 fn assign_moves_to_target_inbox() {
1228 let (_d, ws) = fresh_workspace();
1229 ws.create_queue("review", None).unwrap();
1230 let t = ws.new_task("for review".into(), "".into()).unwrap();
1231 let id = t.id;
1232 ws.push_task(t).unwrap();
1233 let key = ws
1234 .assign_to_queue(TaskIdentifier::Id(id), "review")
1235 .unwrap()
1236 .0;
1237 let stack = ws.read_stack().unwrap();
1238 assert!(stack.is_empty());
1239 ws.switch_queue("review").unwrap();
1240 let inbox = ws.list_inbox().unwrap();
1241 assert_eq!(inbox.len(), 1);
1242 assert_eq!(inbox[0].key, key);
1243 let accepted = ws.accept_inbox(&key).unwrap();
1244 assert_eq!(accepted.0, id.0);
1245 let stack = ws.read_stack().unwrap();
1246 assert_eq!(stack.len(), 1);
1247 }
1248
1249 #[test]
1250 fn reject_returns_to_source_inbox() {
1251 let (_d, ws) = fresh_workspace();
1252 ws.create_queue("review", None).unwrap();
1253 let t = ws.new_task("bounce me".into(), "".into()).unwrap();
1254 let id = t.id;
1255 let stable = t.stable.clone();
1256 ws.push_task(t).unwrap();
1257 let assign_key = ws
1258 .assign_to_queue(TaskIdentifier::Id(id), "review")
1259 .unwrap()
1260 .0;
1261 ws.switch_queue("review").unwrap();
1262 ws.reject_inbox(&assign_key).unwrap();
1263 let inbox_here = ws.list_inbox().unwrap();
1264 assert!(inbox_here.is_empty(), "rejected item must leave receiver inbox");
1265 ws.switch_queue("tsk").unwrap();
1266 let returned = ws.list_inbox().unwrap();
1267 assert_eq!(returned.len(), 1, "rejected item must land in sender inbox");
1268 assert_eq!(returned[0].source_queue, "review");
1269 assert_eq!(returned[0].stable, stable);
1270 }
1271
1272 #[test]
1273 fn pull_only_when_can_pull() {
1274 let (_d, ws) = fresh_workspace();
1275 ws.create_queue("private", Some(false)).unwrap();
1276 ws.switch_queue("private").unwrap();
1277 let t = ws.new_task("private task".into(), "".into()).unwrap();
1278 let id = t.id;
1279 ws.push_task(t).unwrap();
1280 ws.switch_queue("tsk").unwrap();
1281 let r = ws.pull_from_queue("private", TaskIdentifier::Id(id));
1282 assert!(r.is_err(), "pull from can-pull=false queue must fail");
1283 ws.create_queue("private", Some(true)).unwrap();
1284 let pulled = ws.pull_from_queue("private", TaskIdentifier::Id(id)).unwrap();
1285 assert_eq!(pulled.0, id.0);
1286 let stack = ws.read_stack().unwrap();
1287 assert_eq!(stack.len(), 1);
1288 }
1289
1290 #[test]
1291 fn list_namespace_tasks_shows_all_bindings_including_dropped() {
1292 let (_d, ws) = fresh_workspace();
1293 let t1 = ws.new_task("alpha".into(), "".into()).unwrap();
1294 let id1 = t1.id;
1295 ws.push_task(t1).unwrap();
1296 let t2 = ws.new_task("beta".into(), "".into()).unwrap();
1297 let id2 = t2.id;
1298 ws.push_task(t2).unwrap();
1299 // Dropped tasks keep their namespace binding (per status property design).
1300 ws.drop(TaskIdentifier::Id(id1)).unwrap();
1301
1302 let listed = ws.list_namespace_tasks("tsk").unwrap();
1303 let ids: Vec<_> = listed.iter().map(|e| e.id).collect();
1304 assert_eq!(ids, vec![id1, id2]);
1305 let titles: Vec<_> = listed.iter().map(|e| e.title.as_str()).collect();
1306 assert_eq!(titles, vec!["alpha", "beta"]);
1307 }
1308
1309 #[test]
1310 fn log_task_walks_commit_chain_newest_first() {
1311 let (_d, ws) = fresh_workspace();
1312 let t = ws.new_task("v1".into(), "".into()).unwrap();
1313 let id = t.id;
1314 ws.push_task(t).unwrap();
1315
1316 // Two edits (each appends a commit).
1317 let mut t = ws.task(TaskIdentifier::Id(id)).unwrap();
1318 t.title = "v2".into();
1319 ws.save_task(&t).unwrap();
1320 let mut t = ws.task(TaskIdentifier::Id(id)).unwrap();
1321 t.title = "v3".into();
1322 ws.save_task(&t).unwrap();
1323
1324 let log = ws.log_task(TaskIdentifier::Id(id)).unwrap();
1325 // create + 2 edits = 3 commits.
1326 assert_eq!(log.len(), 3);
1327 // Newest first.
1328 assert_eq!(log[0].summary, "edit");
1329 assert_eq!(log[1].summary, "edit");
1330 assert_eq!(log[2].summary, "create");
1331 }
1332
1333 #[test]
1334 fn log_namespace_walks_id_assignments() {
1335 let (_d, ws) = fresh_workspace();
1336 let t1 = ws.new_task("a".into(), "".into()).unwrap();
1337 ws.push_task(t1).unwrap();
1338 let t2 = ws.new_task("b".into(), "".into()).unwrap();
1339 ws.push_task(t2).unwrap();
1340
1341 let log = ws.log_namespace("tsk").unwrap();
1342 // Two id-assignments.
1343 assert!(log.len() >= 2, "got {}", log.len());
1344 assert_eq!(log[0].summary, "assign-id tsk-2");
1345 assert_eq!(log[1].summary, "assign-id tsk-1");
1346 }
1347
1348 #[test]
1349 fn backfill_status_marks_legacy_tasks_open_and_skips_done() {
1350 let (_d, ws) = fresh_workspace();
1351
1352 // Simulate a legacy task: bind a stable id with no status property.
1353 let repo = ws.repo().unwrap();
1354 let raw = object::Task::new("legacy task");
1355 let stable = object::create(&repo, &raw, "create").unwrap();
1356 let h_legacy =
1357 namespace::assign_id(&repo, &ws.namespace(), stable.clone(), "assign").unwrap();
1358 queue::push_top(&repo, &ws.queue(), stable, "push").unwrap();
1359
1360 // Plus a fresh task that already has status=open and a dropped one.
1361 let t_open = ws.new_task("fresh open".into(), "".into()).unwrap();
1362 let id_open = t_open.id;
1363 ws.push_task(t_open).unwrap();
1364 let t_done = ws.new_task("will drop".into(), "".into()).unwrap();
1365 let id_done = t_done.id;
1366 ws.push_task(t_done).unwrap();
1367 ws.drop(TaskIdentifier::Id(id_done)).unwrap();
1368
1369 // Pre-condition: the legacy task has no status.
1370 let read = ws.task(TaskIdentifier::Id(Id(h_legacy))).unwrap();
1371 assert!(!read.attributes.contains_key(STATUS_KEY));
1372
1373 let n = ws.backfill_status().unwrap();
1374 assert_eq!(n, 1, "only the legacy task gets backfilled");
1375
1376 // Legacy is now open, fresh-open stays open, dropped stays done.
1377 let read = ws.task(TaskIdentifier::Id(Id(h_legacy))).unwrap();
1378 assert_eq!(
1379 read.attributes.get(STATUS_KEY),
1380 Some(&vec![STATUS_OPEN.to_string()])
1381 );
1382 let read = ws.task(TaskIdentifier::Id(id_open)).unwrap();
1383 assert_eq!(
1384 read.attributes.get(STATUS_KEY),
1385 Some(&vec![STATUS_OPEN.to_string()])
1386 );
1387 let read = ws.task(TaskIdentifier::Id(id_done)).unwrap();
1388 assert_eq!(
1389 read.attributes.get(STATUS_KEY),
1390 Some(&vec![STATUS_DONE.to_string()])
1391 );
1392
1393 // Re-running is a no-op.
1394 let n = ws.backfill_status().unwrap();
1395 assert_eq!(n, 0);
1396 }
1397
1398 #[test]
1399 fn new_task_starts_open_drop_marks_done() {
1400 let (_d, ws) = fresh_workspace();
1401 let t = ws.new_task("a".into(), "".into()).unwrap();
1402 assert_eq!(
1403 t.attributes.get(STATUS_KEY),
1404 Some(&vec![STATUS_OPEN.to_string()])
1405 );
1406 let id = t.id;
1407 ws.push_task(t).unwrap();
1408
1409 // Index reflects the new open task.
1410 let opens = ws
1411 .find_by_property(STATUS_KEY, Some(STATUS_OPEN))
1412 .unwrap();
1413 assert_eq!(opens.len(), 1);
1414 assert_eq!(opens[0].0, id);
1415
1416 ws.drop(TaskIdentifier::Id(id)).unwrap();
1417
1418 // Status flipped, queue empty, namespace binding kept.
1419 let read = ws.task(TaskIdentifier::Id(id)).unwrap();
1420 assert_eq!(
1421 read.attributes.get(STATUS_KEY),
1422 Some(&vec![STATUS_DONE.to_string()])
1423 );
1424 assert!(ws.read_stack().unwrap().is_empty());
1425 let dones = ws
1426 .find_by_property(STATUS_KEY, Some(STATUS_DONE))
1427 .unwrap();
1428 assert_eq!(dones.len(), 1);
1429 assert_eq!(dones[0].0, id);
1430 }
1431
1432 #[test]
1433 fn init_does_not_create_files_in_working_tree() {
1434 let dir = tempfile::tempdir().unwrap();
1435 run_git_init(dir.path());
1436 Workspace::init(dir.path().to_path_buf()).unwrap();
1437 // The only directory entries in the working tree should be `.git`
1438 // (from `git init`) — no `.tsk` and nothing else.
1439 let entries: Vec<_> = std::fs::read_dir(dir.path())
1440 .unwrap()
1441 .filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned()))
1442 .collect();
1443 assert_eq!(
1444 entries,
1445 vec![".git".to_string()],
1446 "no user-local state should land in the working tree"
1447 );
1448 // The state files should live under <git-dir>/tsk/.
1449 assert!(dir.path().join(".git/tsk/namespace").exists());
1450 assert!(dir.path().join(".git/tsk/queue").exists());
1451 }
1452
1453 #[test]
1454 fn duplicate_content_in_active_ns_done_reopens() {
1455 let (_d, ws) = fresh_workspace();
1456 let t = ws.new_task("clean kitchen".into(), "".into()).unwrap();
1457 let original_id = t.id;
1458 let stable = t.stable.clone();
1459 ws.push_task(t).unwrap();
1460 ws.drop(TaskIdentifier::Id(original_id)).unwrap();
1461 // Task is now status=done. Re-creating with the same content should
1462 // reopen rather than mint a new id.
1463 let again = ws.new_task("clean kitchen".into(), "".into()).unwrap();
1464 assert_eq!(again.id, original_id, "reopened task keeps its human id");
1465 assert_eq!(again.stable, stable);
1466 assert_eq!(
1467 again.attributes.get(STATUS_KEY).unwrap(),
1468 &vec![STATUS_OPEN.to_string()],
1469 "reopen flips status back to open"
1470 );
1471 }
1472
1473 #[test]
1474 fn duplicate_content_open_in_active_ns_is_idempotent() {
1475 let (_d, ws) = fresh_workspace();
1476 let first = ws.new_task("write report".into(), "".into()).unwrap();
1477 let id = first.id;
1478 ws.push_task(first).unwrap();
1479 let second = ws.new_task("write report".into(), "".into()).unwrap();
1480 assert_eq!(second.id, id, "same content returns the same id");
1481 }
1482
1483 #[test]
1484 fn duplicate_content_bound_only_in_other_ns_errors() {
1485 let (_d, ws) = fresh_workspace();
1486 let t = ws.new_task("file taxes".into(), "".into()).unwrap();
1487 let id = t.id;
1488 ws.push_task(t).unwrap();
1489 // Move the binding from the default `tsk` namespace into `alpha`,
1490 // leaving the active `tsk` namespace without a binding.
1491 ws.share(TaskIdentifier::Id(id), "alpha").unwrap();
1492 // Manually unbind from `tsk` (simulating "this content lives only
1493 // in another namespace").
1494 let repo = ws.repo().unwrap();
1495 namespace::unassign_id(&repo, "tsk", id.0, "test-unbind").unwrap();
1496 // Now creating with the same content should refuse.
1497 let err = ws
1498 .new_task("file taxes".into(), "".into())
1499 .expect_err("must error when content lives only in another ns");
1500 let msg = format!("{err}");
1501 assert!(
1502 msg.contains("alpha-"),
1503 "error should reference the foreign binding: {msg}"
1504 );
1505 }
1506
1507 #[test]
1508 fn duplicate_content_unbound_everywhere_binds_in_active_ns() {
1509 let (_d, ws) = fresh_workspace();
1510 let t = ws.new_task("legacy task".into(), "".into()).unwrap();
1511 let id = t.id;
1512 let stable = t.stable.clone();
1513 ws.push_task(t).unwrap();
1514 // Forcibly unbind everywhere to simulate an orphaned task ref.
1515 let repo = ws.repo().unwrap();
1516 namespace::unassign_id(&repo, "tsk", id.0, "test-unbind").unwrap();
1517 // Same content again should re-bind into active ns with a new id.
1518 let again = ws.new_task("legacy task".into(), "".into()).unwrap();
1519 assert_eq!(again.stable, stable, "stable id must match the orphaned ref");
1520 assert_ne!(
1521 again.id, id,
1522 "rebinding allocates a fresh human id from `next`"
1523 );
1524 }
1525
1526 #[test]
1527 fn gc_refs_prunes_all_drift_classes() {
1528 let (_d, ws) = fresh_workspace();
1529 let repo = ws.repo().unwrap();
1530
1531 // 1. Empty non-default queue → pruned.
1532 ws.create_queue("empty", None).unwrap();
1533
1534 // 2. Orphan property index entry pointing at a missing task ref.
1535 let orphan = StableId("0".repeat(40));
1536 properties::set(&repo, "ghost", &orphan, &["x".into()], "test").unwrap();
1537
1538 // 3. Ghost namespace binding: assign_id binds before the task ref
1539 // exists (simulating a partial multi-ref write).
1540 namespace::assign_id(&repo, "tsk", orphan.clone(), "ghost-bind").unwrap();
1541
1542 // 4. Orphan queue index entry pointing at the same missing stable.
1543 queue::push_top(&repo, "tsk", orphan.clone(), "orphan-push").unwrap();
1544
1545 let (queues, props, ghosts, qe) = ws.gc_refs().unwrap();
1546 assert_eq!(queues, 1, "empty queue pruned");
1547 assert_eq!(props, 1, "orphan property entry pruned");
1548 assert_eq!(ghosts, 1, "ghost namespace binding dropped");
1549 assert_eq!(qe, 1, "orphan queue index entry dropped");
1550 assert!(repo.find_reference(&queue::refname("empty")).is_err());
1551 assert!(repo.find_reference(&properties::refname("ghost")).is_err());
1552 assert!(namespace::human_for(&repo, "tsk", &orphan).unwrap().is_none());
1553
1554 // Idempotent: second pass changes nothing.
1555 assert_eq!(ws.gc_refs().unwrap(), (0, 0, 0, 0));
1556 }
1557
1558 #[test]
1559 fn rot_tor_swap_round_trip() {
1560 let (_d, ws) = fresh_workspace();
1561 let mut ids = Vec::new();
1562 for n in 0..3 {
1563 let t = ws.new_task(format!("t{n}"), "".into()).unwrap();
1564 ids.push(t.id);
1565 ws.push_task(t).unwrap();
1566 }
1567 ws.swap_top().unwrap();
1568 let s = ws.read_stack().unwrap();
1569 assert_eq!(
1570 s.iter().map(|e| e.id).collect::<Vec<_>>(),
1571 vec![ids[1], ids[2], ids[0]]
1572 );
1573 ws.swap_top().unwrap();
1574 ws.rot().unwrap();
1575 ws.tor().unwrap();
1576 let s = ws.read_stack().unwrap();
1577 assert_eq!(
1578 s.iter().map(|e| e.id).collect::<Vec<_>>(),
1579 vec![ids[2], ids[1], ids[0]]
1580 );
1581 }
1582}