A file-based task manager
1pub mod errors;
2mod fzf;
3mod namespace;
4mod merge;
5mod object;
6mod patch;
7mod propvalue;
8mod properties;
9mod queue;
10mod task;
11mod workspace;
12
13use clap::{Args, CommandFactory, Parser, Subcommand};
14use clap_complete::{Shell, generate};
15use edit::edit as open_editor;
16use errors::Result;
17use std::env::current_dir;
18use std::io::{self, Read, Write};
19use std::path::PathBuf;
20use std::process::exit;
21use std::str::FromStr as _;
22use workspace::{Id, TaskIdentifier, Workspace};
23
24fn default_dir() -> Result<PathBuf> {
25 Ok(current_dir()?)
26}
27
28fn parse_id(s: &str) -> std::result::Result<Id, &'static str> {
29 Id::from_str(s).map_err(|_| "Unable to parse tsk- ID")
30}
31
32#[derive(Parser)]
33#[command(version, about)]
34struct Cli {
35 /// Override the tsk root directory.
36 #[arg(short = 'C', env = "TSK_ROOT", value_name = "DIR")]
37 dir: Option<PathBuf>,
38 /// Override the active queue for this invocation only. Affects every
39 /// command that reads/writes the active queue (push, drop, swap,
40 /// rot/tor, prioritize/deprioritize, list, inbox, assign, accept,
41 /// reject, export, ...).
42 #[arg(short = 'q', long = "queue", value_name = "QUEUE", global = true)]
43 queue: Option<String>,
44 #[command(subcommand)]
45 command: Commands,
46}
47
48#[derive(Subcommand)]
49enum Commands {
50 /// Bootstrap user-local state in `<git-dir>/tsk/`. (Auto-created on first use.)
51 Init,
52 /// Create a new task and push it onto the active queue.
53 Push {
54 #[arg(short = 'e', default_value_t = false)]
55 edit: bool,
56 #[arg(short = 'b')]
57 body: Option<String>,
58 #[command(flatten)]
59 title: Title,
60 },
61 /// Create a new task and append it to the bottom of the active queue.
62 Append {
63 #[arg(short = 'e', default_value_t = false)]
64 edit: bool,
65 #[arg(short = 'b')]
66 body: Option<String>,
67 #[command(flatten)]
68 title: Title,
69 },
70 /// Print the active queue's stack (top-of-stack first).
71 List {
72 #[arg(short = 'a', default_value_t = false)]
73 all: bool,
74 #[arg(short = 'c', default_value_t = 10)]
75 count: usize,
76 #[arg(short = 'i', default_value_t = false)]
77 ids_only: bool,
78 },
79 /// Show a task by id.
80 Show {
81 /// Print xattr-style YAML front-matter for the task's properties.
82 #[arg(short = 'x', default_value_t = false)]
83 show_attrs: bool,
84 /// Skip the rich-text parser and print the raw bytes verbatim.
85 #[arg(short = 'R', default_value_t = false)]
86 raw: bool,
87 #[command(flatten)]
88 task_id: TaskId,
89 },
90 /// Open `$EDITOR` to modify a task.
91 Edit {
92 #[command(flatten)]
93 task_id: TaskId,
94 },
95 /// Drop a task (remove from queue + unbind human id, history retained).
96 Drop {
97 #[command(flatten)]
98 task_id: TaskId,
99 },
100 /// Flip a `done` task back to `open` and push it onto the active queue.
101 Reopen {
102 #[command(flatten)]
103 task_id: TaskId,
104 },
105 /// Swap the top two tasks.
106 Swap,
107 /// Rotate top 3: third → top.
108 Rot,
109 /// Reverse-rotate top 3: top → third.
110 Tor,
111 /// Move a task to the top of the stack.
112 Prioritize {
113 #[command(flatten)]
114 task_id: TaskId,
115 },
116 /// Move a task to the bottom of the stack.
117 Deprioritize {
118 #[command(flatten)]
119 task_id: TaskId,
120 },
121 /// Drop index entries whose stable ids no longer resolve.
122 Clean,
123 /// Run every known one-shot migration against the active workspace.
124 /// Currently: backfill `status=open` on tasks without a status property.
125 /// New migrations land here as they're added.
126 FixUp,
127 /// Export one or more tasks as a concatenated mbox-format patch series.
128 /// With no -T / --where / --all, drops into fzf for a single-task pick.
129 /// Pipe to a file for offline transfer; recipient runs `tsk import`.
130 Export {
131 /// Specific task by tsk-id. Repeatable for multi-task export.
132 #[arg(short = 'T', value_parser = parse_id)]
133 ids: Vec<Id>,
134 /// Property filter: `--where status=open`. Combines with -T flags.
135 #[arg(long, value_name = "KEY=VALUE")]
136 r#where: Option<String>,
137 /// Export every task bound in the active namespace.
138 #[arg(long)]
139 all: bool,
140 /// Embed each task's namespace+human-id in its root entry so the
141 /// recipient can opt in to mirroring the bindings on import.
142 #[arg(long)]
143 bind: bool,
144 },
145 /// Import a task from an mbox-format patch series (read from stdin).
146 /// Verifies stable id; rejects tampered patches.
147 Import {
148 /// Bind the imported task into the active namespace, allocating a
149 /// fresh human id (or reusing an existing binding to the same stable id).
150 #[arg(long)]
151 bind: bool,
152 },
153 /// Print the commit history of a tsk ref. Newest commit first.
154 Log {
155 #[command(subcommand)]
156 target: LogTarget,
157 },
158 /// Print refspec/setup hints for `git push`/`git fetch` to include `refs/tsk/*`.
159 GitSetup {
160 /// Configure push/fetch refspecs on the named remote (default: origin).
161 #[arg(short = 'r')]
162 remote: Option<String>,
163 },
164 /// Push tsk refs to a git remote (default: origin).
165 GitPush {
166 remote: Option<String>,
167 },
168 /// Fetch tsk refs from a git remote (default: origin) and reconcile
169 /// divergent task histories. Default strategy is merge; pass --rebase
170 /// to replay local-only commits onto the remote tip instead.
171 GitPull {
172 remote: Option<String>,
173 #[arg(long)]
174 rebase: bool,
175 },
176 /// Share a task into another namespace (binds same stable id under that namespace's next human id).
177 Share {
178 target: String,
179 #[command(flatten)]
180 task_id: TaskId,
181 },
182 /// Move a task from the active queue's index into another queue's inbox.
183 Assign {
184 /// Target queue. Omit to fzf-pick from existing queues.
185 target: Option<String>,
186 #[command(flatten)]
187 task_id: TaskId,
188 /// Auto-push refs to this remote after assigning. Empty string skips. Default: origin.
189 #[arg(short = 'R')]
190 remote: Option<String>,
191 },
192 /// Pull a task from another queue's index (only allowed if its can-pull is true).
193 Pull {
194 source: String,
195 #[command(flatten)]
196 task_id: TaskId,
197 },
198 /// List inbox items pending in the active queue.
199 Inbox {
200 /// Auto-pull from this remote first. Empty string skips. Default: origin.
201 #[arg(short = 'R')]
202 remote: Option<String>,
203 },
204 /// Accept an inbox item by key (no key = first item).
205 Accept {
206 key: Option<String>,
207 /// Auto-push refs to this remote after accepting. Empty string skips. Default: origin.
208 #[arg(short = 'R')]
209 remote: Option<String>,
210 },
211 /// Reject an inbox item by key (no key = first item).
212 Reject {
213 key: Option<String>,
214 /// Auto-push refs to this remote after rejecting. Empty string skips. Default: origin.
215 #[arg(short = 'R')]
216 remote: Option<String>,
217 },
218 /// Get/set/find tasks by property. Properties are zero-or-more text values
219 /// stored as files in the task's tree object; each value is one line.
220 Prop {
221 #[command(subcommand)]
222 action: PropAction,
223 },
224 /// Manage namespaces.
225 Namespace {
226 #[command(subcommand)]
227 action: NamespaceAction,
228 },
229 /// Manage queues.
230 Queue {
231 #[command(subcommand)]
232 action: QueueAction,
233 },
234 /// Manage git remotes that carry tsk refs.
235 Remote {
236 #[command(subcommand)]
237 action: RemoteAction,
238 },
239 /// Switch active namespace (shorthand). With no name, fzf-picks from
240 /// existing namespaces (plus a `<new>` sentinel for creating one on
241 /// the fly).
242 Switch { name: Option<String> },
243 /// Generate shell completion.
244 Completion {
245 #[arg(short = 's')]
246 shell: Shell,
247 },
248}
249
250#[derive(Subcommand)]
251enum LogTarget {
252 /// Edit history of a single task.
253 Task {
254 #[command(flatten)]
255 task_id: TaskId,
256 },
257 /// Edit history of a namespace tree (id assignments, drops, shares).
258 /// Defaults to the active namespace.
259 Namespace { name: Option<String> },
260 /// Edit history of a queue tree (pushes, drops, inbox moves).
261 /// Defaults to the active queue.
262 Queue { name: Option<String> },
263}
264
265#[derive(Subcommand)]
266enum PropAction {
267 /// List all values for every property on a task.
268 List {
269 #[command(flatten)]
270 task_id: TaskId,
271 },
272 /// Append a value to a property on a task. Creates the property if absent.
273 Add {
274 #[command(flatten)]
275 task_id: TaskId,
276 key: String,
277 value: String,
278 },
279 /// Replace the entire value list for a property. With no values, removes the property.
280 Set {
281 #[command(flatten)]
282 task_id: TaskId,
283 key: String,
284 values: Vec<String>,
285 },
286 /// Remove a single value (or, with no value, the entire property).
287 Unset {
288 #[command(flatten)]
289 task_id: TaskId,
290 key: String,
291 value: Option<String>,
292 },
293 /// List every property key currently in use across the workspace.
294 Keys,
295 /// List distinct values seen for a property key.
296 Values { key: String },
297 /// Find every task in the active namespace whose `key` is set (and equals
298 /// `value`, if supplied). With both omitted, fzf-picks the key, then value.
299 Find {
300 key: Option<String>,
301 value: Option<String>,
302 },
303}
304
305#[derive(Subcommand)]
306enum NamespaceAction {
307 List,
308 Current,
309 /// Switch active namespace. With no name, fzf-picks from existing
310 /// namespaces (plus a `<new>` sentinel for creating one on the fly).
311 Switch { name: Option<String> },
312 /// List every task bound in a namespace (defaults to active),
313 /// regardless of which queue (if any) it's on. One row per id.
314 Tasks { name: Option<String> },
315}
316
317#[derive(Subcommand)]
318enum QueueAction {
319 List,
320 Current,
321 /// Create a new queue. By default `can-pull=false`; use `-p` to make it true.
322 Create {
323 name: String,
324 #[arg(short = 'p', default_value_t = false)]
325 can_pull: bool,
326 },
327 /// Switch active queue. With no name, fzf-picks from existing queues
328 /// (plus a `<new>` sentinel for creating one on the fly).
329 Switch { name: Option<String> },
330}
331
332#[derive(Subcommand)]
333enum RemoteAction {
334 /// Print the active default remote (the one used when no `-R` is given).
335 Default,
336 /// Persist the active default remote for this clone. Must already be
337 /// a git remote — use `git remote add ...` (and `tsk git-setup -r
338 /// <name>` to configure refspecs) first.
339 SetDefault { name: String },
340}
341
342#[derive(Args)]
343#[group(required = true, multiple = false)]
344struct Title {
345 #[arg(short, value_name = "TITLE")]
346 title: Option<String>,
347 #[arg(value_name = "TITLE")]
348 title_simple: Option<Vec<String>>,
349}
350
351#[derive(Args, Default)]
352#[group(required = false, multiple = false)]
353struct TaskId {
354 #[arg(short = 't', value_name = "ID")]
355 id: Option<u32>,
356 #[arg(short = 'T', value_name = "TSK-ID", value_parser = parse_id)]
357 tsk_id: Option<Id>,
358 #[arg(short = 'r', value_name = "RELATIVE")]
359 relative_id: Option<u32>,
360}
361
362impl TaskId {
363 /// True when the user passed none of `-t`, `-T`, or `-r`. Commands
364 /// that fall back to a fuzzy finder use this to decide whether to
365 /// prompt; commands that prefer "top of stack" silently treat this
366 /// as `Relative(0)` via the `From` impl.
367 fn is_empty(&self) -> bool {
368 self.id.is_none() && self.tsk_id.is_none() && self.relative_id.is_none()
369 }
370
371 /// Resolve to a `TaskIdentifier`, dropping into an fzf picker when no
372 /// flag was supplied. Use when interactive selection is the desired
373 /// fallback (e.g. `tsk export`); otherwise prefer `Into`, which
374 /// silently picks the top of the stack.
375 fn resolve_or_pick(self, ws: &Workspace) -> Result<TaskIdentifier> {
376 if !self.is_empty() {
377 return Ok(self.into());
378 }
379 let entries = ws.list_namespace_tasks(&ws.namespace())?;
380 if entries.is_empty() {
381 return Err(errors::Error::NoTasks);
382 }
383 let lines: Vec<String> = entries
384 .iter()
385 .map(|e| format!("{}\t{}", e.id, e.title))
386 .collect();
387 let picked: Option<String> = fzf::select(lines, ["--prompt=task> "])?;
388 let picked = picked.ok_or(errors::Error::NoTasks)?;
389 let id_str = picked.split('\t').next().unwrap_or("");
390 let id: Id =
391 parse_id(id_str).map_err(|e| errors::Error::Parse(e.to_string()))?;
392 Ok(TaskIdentifier::Id(id))
393 }
394}
395
396impl From<TaskId> for TaskIdentifier {
397 fn from(v: TaskId) -> Self {
398 if let Some(id) = v.id.map(Id::from).or(v.tsk_id) {
399 TaskIdentifier::Id(id)
400 } else {
401 TaskIdentifier::Relative(v.relative_id.unwrap_or(0))
402 }
403 }
404}
405
406fn effective_remote(ws: &Workspace, supplied: Option<String>) -> Option<String> {
407 supplied
408 .map(|s| if s.is_empty() { None } else { Some(s) })
409 .unwrap_or_else(|| Some(ws.default_remote()))
410}
411
412/// Scoped push (best-effort, silent on `-R ""`).
413fn auto_push_refs(ws: &Workspace, remote: Option<String>, refs: Vec<String>) {
414 if let Some(r) = effective_remote(ws, remote) {
415 let _ = ws.git_push_refs(&r, &refs);
416 }
417}
418
419fn dispatch(cli: Cli) -> Result<()> {
420 workspace::set_queue_override(cli.queue);
421 let dir = match cli.dir {
422 Some(d) => d,
423 None => default_dir()?,
424 };
425 match cli.command {
426 Commands::Init => Workspace::init(dir),
427 Commands::Push { edit, body, title } => command_push(dir, edit, body, title, true),
428 Commands::Append { edit, body, title } => command_push(dir, edit, body, title, false),
429 Commands::List {
430 all,
431 count,
432 ids_only,
433 } => command_list(dir, all, count, ids_only),
434 Commands::Show {
435 task_id,
436 show_attrs,
437 raw,
438 } => command_show(dir, task_id, show_attrs, raw),
439 Commands::Edit { task_id } => command_edit(dir, task_id),
440 Commands::Drop { task_id } => command_drop(dir, task_id),
441 Commands::Reopen { task_id } => {
442 let id = Workspace::from_path(dir)?.reopen(task_id.into())?;
443 println!("Reopened {id}");
444 Ok(())
445 }
446 Commands::Swap => Workspace::from_path(dir)?.swap_top(),
447 Commands::Rot => Workspace::from_path(dir)?.rot(),
448 Commands::Tor => Workspace::from_path(dir)?.tor(),
449 Commands::Prioritize { task_id } => {
450 Workspace::from_path(dir)?.prioritize(task_id.into())
451 }
452 Commands::Deprioritize { task_id } => {
453 Workspace::from_path(dir)?.deprioritize(task_id.into())
454 }
455 Commands::Clean => Workspace::from_path(dir)?.clean(),
456 Commands::Export {
457 ids,
458 r#where,
459 all,
460 bind,
461 } => command_export(dir, ids, r#where, all, bind),
462 Commands::Import { bind } => command_import(dir, bind),
463 Commands::Log { target } => command_log(dir, target),
464 Commands::FixUp => {
465 let ws = Workspace::from_path(dir)?;
466 let n = ws.backfill_status()?;
467 println!("backfill-status: set status=open on {n} task(s)");
468 let m = ws.migrate_property_encoding()?;
469 println!("migrate-property-encoding: rewrote {m} task(s)");
470 let (q, p, b, qe) = ws.gc_refs()?;
471 println!(
472 "gc-refs: pruned {q} empty queue(s), {p} orphan property entries, \
473 {b} ghost namespace binding(s), {qe} orphan queue index entries"
474 );
475 Ok(())
476 }
477 Commands::GitSetup { remote } => {
478 let ws = Workspace::from_path(dir)?;
479 let r = remote.unwrap_or_else(|| ws.default_remote());
480 ws.configure_git_remote_refspecs(&r)
481 }
482 Commands::GitPush { remote } => {
483 let ws = Workspace::from_path(dir)?;
484 let r = remote.unwrap_or_else(|| ws.default_remote());
485 ws.git_push(&r)
486 }
487 Commands::GitPull { remote, rebase } => {
488 let ws = Workspace::from_path(dir)?;
489 let r = remote.unwrap_or_else(|| ws.default_remote());
490 let strategy = if rebase {
491 merge::Strategy::Rebase
492 } else {
493 merge::Strategy::Merge
494 };
495 let outcome = ws.git_pull_with_strategy(&r, strategy)?;
496 for rec in &outcome.tasks {
497 if !matches!(rec.kind, merge::ReconKind::Unchanged) {
498 let short = &rec.stable.0[..12.min(rec.stable.0.len())];
499 println!("{:?} {short}", rec.kind);
500 }
501 }
502 for nr in &outcome.namespaces {
503 for (old, new) in &nr.renumbers {
504 println!(
505 "{}-{} → {}-{} (conflict with {r})",
506 nr.namespace, old, nr.namespace, new
507 );
508 }
509 }
510 for qr in &outcome.queues {
511 println!("merged queue {}", qr.name);
512 }
513 Ok(())
514 }
515 Commands::Share { target, task_id } => command_share(dir, target, task_id),
516 Commands::Assign {
517 target,
518 task_id,
519 remote,
520 } => command_assign(dir, target, task_id, remote),
521 Commands::Pull { source, task_id } => command_pull(dir, source, task_id),
522 Commands::Inbox { remote } => command_inbox(dir, remote),
523 Commands::Accept { key, remote } => command_accept(dir, key, remote),
524 Commands::Reject { key, remote } => command_reject(dir, key, remote),
525 Commands::Prop { action } => command_prop(dir, action),
526 Commands::Namespace { action } => command_namespace(dir, action),
527 Commands::Queue { action } => command_queue(dir, action),
528 Commands::Remote { action } => command_remote(dir, action),
529 Commands::Switch { name } => {
530 resolve_and_switch_namespace(&Workspace::from_path(dir)?, name)
531 }
532 Commands::Completion { shell } => {
533 generate(shell, &mut Cli::command(), "tsk", &mut io::stdout());
534 Ok(())
535 }
536 }
537}
538
539/// Parse the CLI from `std::env::args()` and execute. Returns the process
540/// exit code so callers (the `tsk` and `git-tsk` bins) can hand it to
541/// `std::process::exit`.
542pub fn run() -> i32 {
543 match dispatch(Cli::parse()) {
544 Ok(()) => 0,
545 Err(e) => {
546 eprintln!("{e}");
547 2
548 }
549 }
550}
551
552fn read_title_and_body(
553 edit: bool,
554 body: Option<String>,
555 title_arg: Title,
556) -> Result<(String, String)> {
557 let mut title = if let Some(t) = title_arg.title {
558 t
559 } else if let Some(ts) = title_arg.title_simple {
560 ts.join(" ")
561 } else {
562 String::new()
563 };
564 let mut body = if body.is_none() {
565 if let Some((first, rest)) = title.split_once('\n') {
566 let extracted = rest.to_string();
567 title = first.to_string();
568 extracted
569 } else {
570 String::new()
571 }
572 } else {
573 title = title.replace(['\n', '\r'], " ");
574 body.unwrap_or_default()
575 };
576 if body == "-" {
577 body.clear();
578 io::stdin().read_to_string(&mut body)?;
579 }
580 if edit {
581 let new_content = open_editor(format!("{title}\n\n{body}"))?;
582 if let Some((t, b)) = new_content.split_once('\n') {
583 title = t.to_string();
584 body = b.trim_start_matches('\n').to_string();
585 }
586 }
587 title = title.replace(['\n', '\r'], " ");
588 Ok((title, body))
589}
590
591fn command_push(
592 dir: PathBuf,
593 edit: bool,
594 body: Option<String>,
595 title: Title,
596 on_top: bool,
597) -> Result<()> {
598 let (title, body) = read_title_and_body(edit, body, title)?;
599 let ws = Workspace::from_path(dir)?;
600 let task = ws.new_task(title, body)?;
601 if on_top {
602 ws.push_task(task)
603 } else {
604 ws.append_task(task)
605 }
606}
607
608fn command_list(dir: PathBuf, all: bool, count: usize, ids_only: bool) -> Result<()> {
609 let ws = Workspace::from_path(dir)?;
610 let stack = ws.read_stack()?;
611 if stack.is_empty() {
612 println!("*No tasks*");
613 return Ok(());
614 }
615 for (i, entry) in stack.iter().enumerate() {
616 if !all && i >= count {
617 break;
618 }
619 if ids_only {
620 println!("{}", entry.id);
621 } else {
622 println!("{}\t{}", entry.id, entry.title);
623 }
624 }
625 Ok(())
626}
627
628fn command_show(dir: PathBuf, task_id: TaskId, show_attrs: bool, raw: bool) -> Result<()> {
629 let ws = Workspace::from_path(dir)?;
630 let task = ws.task(task_id.into())?;
631 if show_attrs && !task.attributes.is_empty() {
632 println!("---");
633 for (k, vs) in &task.attributes {
634 for v in vs {
635 println!("{k}: \"{v}\"");
636 }
637 }
638 println!("---");
639 }
640 let plain = task.to_string();
641 match (raw, task::parse(&plain)) {
642 (false, Some(parsed)) => {
643 print!("{}", parsed.content);
644 // Footnote section: resolve each [[...]] link against the active
645 // namespace (or just echo for foreign / external links).
646 if !parsed.links.is_empty() {
647 println!();
648 for (i, link) in parsed.links.iter().enumerate() {
649 println!("\n{} {}", task::super_num(i + 1), render_link(&ws, link));
650 }
651 }
652 }
653 _ => print!("{plain}"),
654 }
655 println!();
656 Ok(())
657}
658
659fn render_link(ws: &Workspace, link: &task::ParsedLink) -> String {
660 use task::ParsedLink::*;
661 match link {
662 Internal(id) => match ws.task((*id).into()) {
663 Ok(t) => format!("{id}: {}", t.title),
664 Err(_) => format!("{id}: <not bound in '{}'>", ws.namespace()),
665 },
666 Namespaced { namespace, id } => format!("{namespace}/{id}"),
667 Foreign { prefix, id } => format!("{prefix}-{id} (foreign)"),
668 External(url) => url.to_string(),
669 }
670}
671
672fn command_edit(dir: PathBuf, task_id: TaskId) -> Result<()> {
673 let ws = Workspace::from_path(dir)?;
674 let mut task = ws.task(task_id.into())?;
675 let new_content = open_editor(format!("{}\n\n{}", task.title.trim(), task.body.trim()))?;
676 if let Some((t, b)) = new_content.split_once('\n') {
677 task.title = t.replace(['\n', '\r'], " ");
678 task.body = b.trim_start_matches('\n').to_string();
679 ws.save_task(&task)?;
680 }
681 Ok(())
682}
683
684fn command_drop(dir: PathBuf, task_id: TaskId) -> Result<()> {
685 if let Some(id) = Workspace::from_path(dir)?.drop(task_id.into())? {
686 println!("Dropped {id}");
687 Ok(())
688 } else {
689 eprintln!("No task to drop.");
690 exit(1);
691 }
692}
693
694fn command_share(dir: PathBuf, target: String, task_id: TaskId) -> Result<()> {
695 let ws = Workspace::from_path(dir)?;
696 let h = ws.share(task_id.into(), &target)?;
697 println!("Shared as {target}/tsk-{h}");
698 Ok(())
699}
700
701fn command_assign(
702 dir: PathBuf,
703 target: Option<String>,
704 task_id: TaskId,
705 remote: Option<String>,
706) -> Result<()> {
707 let ws = Workspace::from_path(dir)?;
708 let target = match target {
709 Some(t) => t,
710 None => pick_assign_target(&ws)?,
711 };
712 let (key, stable) = ws.assign_to_queue(task_id.into(), &target)?;
713 println!("Assigned to {target} as {key}");
714 auto_push_refs(&ws, remote, ws.refs_for_assign_out(&target, &stable)?);
715 Ok(())
716}
717
718fn pick_assign_target(ws: &Workspace) -> Result<String> {
719 let cur = ws.queue();
720 let candidates: Vec<String> = ws
721 .list_queues()?
722 .into_iter()
723 .filter(|q| q != &cur)
724 .collect();
725 if candidates.is_empty() {
726 return Err(errors::Error::Parse(
727 "No other queues to assign to".into(),
728 ));
729 }
730 fzf::select::<_, String, _>(candidates, ["--prompt=assign to> "])?
731 .ok_or_else(|| errors::Error::Parse("No queue selected".into()))
732}
733
734fn command_pull(dir: PathBuf, source: String, task_id: TaskId) -> Result<()> {
735 let ws = Workspace::from_path(dir)?;
736 // For pull, the task id is interpreted in the source queue's namespace
737 // mapping context. Simplification: require the caller to use -T <stable>
738 // form via human id in active namespace. For v1 we just resolve in
739 // active namespace; sharing first lets the user reference foreign tasks.
740 let id = ws.pull_from_queue(&source, task_id.into())?;
741 println!("Pulled {id}");
742 Ok(())
743}
744
745fn command_inbox(dir: PathBuf, remote: Option<String>) -> Result<()> {
746 let ws = Workspace::from_path(dir)?;
747 if let Some(r) = effective_remote(&ws, remote) {
748 let refs = ws.refs_for_inbox_pull();
749 let _ = ws.git_fetch_refs(&r, &refs);
750 }
751 let inbox = ws.list_inbox()?;
752 if inbox.is_empty() {
753 println!("*Empty*");
754 return Ok(());
755 }
756 for item in inbox {
757 println!("{}\tfrom {}\t{}", item.key, item.source_queue, item.title);
758 }
759 Ok(())
760}
761
762fn pick_inbox_key(ws: &Workspace, key: Option<String>) -> Result<String> {
763 if let Some(k) = key {
764 return Ok(k);
765 }
766 Ok(ws
767 .list_inbox()?
768 .into_iter()
769 .next()
770 .ok_or_else(|| errors::Error::Parse("Inbox is empty".into()))?
771 .key)
772}
773
774fn command_accept(dir: PathBuf, key: Option<String>, remote: Option<String>) -> Result<()> {
775 let ws = Workspace::from_path(dir)?;
776 let key = pick_inbox_key(&ws, key)?;
777 let id = ws.accept_inbox(&key)?;
778 println!("Accepted as {id}");
779 auto_push_refs(&ws, remote, ws.refs_for_accept_inbox());
780 Ok(())
781}
782
783fn command_reject(dir: PathBuf, key: Option<String>, remote: Option<String>) -> Result<()> {
784 let ws = Workspace::from_path(dir)?;
785 let key = pick_inbox_key(&ws, key)?;
786 ws.reject_inbox(&key)?;
787 let source = key.rsplit_once('-').map(|(s, _)| s.to_string());
788 match &source {
789 Some(src) => println!("Rejected {key} (returned to '{src}' inbox)"),
790 None => println!("Rejected {key}"),
791 }
792 if let Some(src) = source {
793 auto_push_refs(&ws, remote, ws.refs_for_reject_inbox(&src));
794 }
795 Ok(())
796}
797
798fn command_export(
799 dir: PathBuf,
800 ids: Vec<Id>,
801 where_: Option<String>,
802 all: bool,
803 bind: bool,
804) -> Result<()> {
805 let ws = Workspace::from_path(dir)?;
806 let mut identifiers: Vec<TaskIdentifier> = ids.into_iter().map(Into::into).collect();
807 if all {
808 for entry in ws.list_namespace_tasks(&ws.namespace())? {
809 identifiers.push(TaskIdentifier::Id(entry.id));
810 }
811 }
812 if let Some(spec) = where_ {
813 let (key, value) = spec
814 .split_once('=')
815 .ok_or_else(|| errors::Error::Parse("expected --where KEY=VALUE".into()))?;
816 for (id, _stable, _title) in ws.find_by_property(key, Some(value))? {
817 identifiers.push(TaskIdentifier::Id(id));
818 }
819 }
820 if identifiers.is_empty() {
821 // Interactive fallback: fzf single-pick.
822 identifiers.push(TaskId::default().resolve_or_pick(&ws)?);
823 }
824 // Dedupe while preserving order.
825 let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
826 identifiers.retain(|i| !matches!(i, TaskIdentifier::Id(id) if !seen.insert(id.0)));
827 let mbox = ws.export_tasks(&identifiers, bind)?;
828 print!("{mbox}");
829 Ok(())
830}
831
832fn command_import(dir: PathBuf, bind: bool) -> Result<()> {
833 let ws = Workspace::from_path(dir)?;
834 let mut buf = String::new();
835 std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
836 let outcomes = ws.import_task(&buf, bind)?;
837 for res in &outcomes {
838 let bound = if let Some(id) = res.bound_human {
839 format!(" bound as {}-{}", ws.namespace(), id)
840 } else {
841 String::new()
842 };
843 println!(
844 "Imported {} commit(s) for task {}{bound}",
845 res.commits_imported, res.stable
846 );
847 }
848 Ok(())
849}
850
851fn command_log(dir: PathBuf, target: LogTarget) -> Result<()> {
852 let ws = Workspace::from_path(dir)?;
853 let commits = match target {
854 LogTarget::Task { task_id } => ws.log_task(task_id.into())?,
855 LogTarget::Namespace { name } => {
856 ws.log_namespace(&name.unwrap_or_else(|| ws.namespace()))?
857 }
858 LogTarget::Queue { name } => ws.log_queue(&name.unwrap_or_else(|| ws.queue()))?,
859 };
860 for c in commits {
861 // git-log --oneline-style: short oid, summary, then author + date below.
862 let short = &c.oid[..c.oid.len().min(8)];
863 println!("{short} {}", c.summary);
864 println!(" {} ({})", c.author, format_unix(c.timestamp));
865 }
866 Ok(())
867}
868
869fn format_unix(ts: i64) -> String {
870 let now = std::time::SystemTime::now()
871 .duration_since(std::time::UNIX_EPOCH)
872 .map(|d| d.as_secs() as i64)
873 .unwrap_or(0);
874 let delta = now - ts;
875 if delta < 0 {
876 return "in the future".to_string();
877 }
878 relative_time(delta as u64)
879}
880
881fn relative_time(secs: u64) -> String {
882 const M: u64 = 60;
883 const H: u64 = 60 * M;
884 const D: u64 = 24 * H;
885 if secs < M {
886 format!("{secs}s ago")
887 } else if secs < H {
888 format!("{}m ago", secs / M)
889 } else if secs < D {
890 format!("{}h ago", secs / H)
891 } else if secs < 30 * D {
892 format!("{}d ago", secs / D)
893 } else if secs < 365 * D {
894 format!("{}mo ago", secs / (30 * D))
895 } else {
896 format!("{}y ago", secs / (365 * D))
897 }
898}
899
900fn command_prop(dir: PathBuf, action: PropAction) -> Result<()> {
901 let ws = Workspace::from_path(dir)?;
902 match action {
903 PropAction::List { task_id } => {
904 let task = ws.task(task_id.into())?;
905 for (k, vs) in &task.attributes {
906 for v in vs {
907 println!("{k}\t{v}");
908 }
909 }
910 }
911 PropAction::Add {
912 task_id,
913 key,
914 value,
915 } => ws.add_property_value(task_id.into(), &key, &value)?,
916 PropAction::Set {
917 task_id,
918 key,
919 values,
920 } => ws.set_property(task_id.into(), &key, values)?,
921 PropAction::Unset {
922 task_id,
923 key,
924 value,
925 } => ws.unset_property(task_id.into(), &key, value.as_deref())?,
926 PropAction::Keys => print_lines(ws.property_keys()?),
927 PropAction::Values { key } => print_lines(ws.property_values(&key)?),
928 PropAction::Find { key, value } => {
929 let key = match key {
930 Some(k) => k,
931 None => fzf::select::<_, String, _>(
932 ws.property_keys()?,
933 ["--prompt=key> "],
934 )?
935 .ok_or_else(|| errors::Error::Parse("No key selected".into()))?,
936 };
937 let value = match value {
938 Some(v) if v == "<any>" => None,
939 Some(v) => Some(v),
940 None => {
941 let mut choices = ws.property_values(&key)?;
942 choices.insert(0, "<any>".to_string());
943 let picked = fzf::select::<_, String, _>(
944 choices,
945 ["--prompt=value> "],
946 )?
947 .ok_or_else(|| errors::Error::Parse("No value selected".into()))?;
948 if picked == "<any>" {
949 None
950 } else {
951 Some(picked)
952 }
953 }
954 };
955 for (id, _stable, title) in ws.find_by_property(&key, value.as_deref())? {
956 println!("{id}\t{title}");
957 }
958 }
959 }
960 Ok(())
961}
962
963fn print_lines<I: std::fmt::Display>(items: impl IntoIterator<Item = I>) {
964 for i in items {
965 println!("{i}");
966 }
967}
968
969fn command_namespace(dir: PathBuf, action: NamespaceAction) -> Result<()> {
970 let ws = Workspace::from_path(dir)?;
971 match action {
972 NamespaceAction::List => print_lines(ws.list_namespaces()?),
973 NamespaceAction::Current => println!("{}", ws.namespace()),
974 NamespaceAction::Switch { name } => return resolve_and_switch_namespace(&ws, name),
975 NamespaceAction::Tasks { name } => {
976 let target = name.unwrap_or_else(|| ws.namespace());
977 for entry in ws.list_namespace_tasks(&target)? {
978 println!("{}\t{}", entry.id, entry.title);
979 }
980 }
981 }
982 Ok(())
983}
984
985fn command_remote(dir: PathBuf, action: RemoteAction) -> Result<()> {
986 let ws = Workspace::from_path(dir)?;
987 match action {
988 RemoteAction::Default => println!("{}", ws.default_remote()),
989 RemoteAction::SetDefault { name } => {
990 ws.set_default_remote(&name)?;
991 println!("Default remote set to '{name}'");
992 }
993 }
994 Ok(())
995}
996
997fn command_queue(dir: PathBuf, action: QueueAction) -> Result<()> {
998 let ws = Workspace::from_path(dir)?;
999 match action {
1000 QueueAction::List => print_lines(ws.list_queues()?),
1001 QueueAction::Current => println!("{}", ws.queue()),
1002 QueueAction::Create { name, can_pull } => {
1003 ws.create_queue(&name, Some(can_pull))?;
1004 println!("Created queue '{name}' (can-pull={can_pull})");
1005 }
1006 QueueAction::Switch { name } => return resolve_and_switch_queue(&ws, name),
1007 }
1008 Ok(())
1009}
1010
1011const NEW_NS_SENTINEL: &str = "<new>";
1012
1013fn resolve_and_switch_namespace(ws: &Workspace, name: Option<String>) -> Result<()> {
1014 let target = match name {
1015 Some(n) => n,
1016 None => pick_with_new(&ws.list_namespaces()?, &ws.namespace(), "namespace")?,
1017 };
1018 ws.switch_namespace(&target)?;
1019 println!("Switched to namespace '{target}'");
1020 Ok(())
1021}
1022
1023fn resolve_and_switch_queue(ws: &Workspace, name: Option<String>) -> Result<()> {
1024 let target = match name {
1025 Some(n) => n,
1026 None => {
1027 let picked = pick_with_new(&ws.list_queues()?, &ws.queue(), "queue")?;
1028 if !ws.list_queues()?.iter().any(|q| q == &picked) {
1029 ws.create_queue(&picked, None)?;
1030 }
1031 picked
1032 }
1033 };
1034 ws.switch_queue(&target)?;
1035 println!("Switched to queue '{target}'");
1036 Ok(())
1037}
1038
1039fn pick_with_new(existing: &[String], current: &str, label: &str) -> Result<String> {
1040 let entries = picker_entries(existing, current);
1041 let picked = fzf::select::<_, String, _>(entries, [format!("--prompt={label}> ")])?
1042 .ok_or_else(|| errors::Error::Parse(format!("No {label} selected")))?;
1043 let picked = strip_picker_marker(&picked);
1044 if picked == NEW_NS_SENTINEL {
1045 let name = prompt_line(&format!("New {label} name: "))?;
1046 if name.is_empty() {
1047 return Err(errors::Error::Parse(format!("Empty {label} name")));
1048 }
1049 Ok(name)
1050 } else {
1051 Ok(picked.to_string())
1052 }
1053}
1054
1055/// Build the fzf input lines: every existing entry (active marked with
1056/// `* `, others with ` `) plus a trailing `<new>` sentinel for creating
1057/// one on the fly. The active entry is always present even when no refs
1058/// have been written yet.
1059fn picker_entries(existing: &[String], current: &str) -> Vec<String> {
1060 let mut entries: Vec<String> = existing
1061 .iter()
1062 .map(|n| {
1063 if n == current {
1064 format!("* {n}")
1065 } else {
1066 format!(" {n}")
1067 }
1068 })
1069 .collect();
1070 if !existing.iter().any(|n| n == current) {
1071 entries.insert(0, format!("* {current}"));
1072 }
1073 entries.push(NEW_NS_SENTINEL.to_string());
1074 entries
1075}
1076
1077fn strip_picker_marker(s: &str) -> &str {
1078 s.strip_prefix("* ").or_else(|| s.strip_prefix(" ")).unwrap_or(s)
1079}
1080
1081fn prompt_line(prompt: &str) -> Result<String> {
1082 eprint!("{prompt}");
1083 io::stderr().flush()?;
1084 let mut s = String::new();
1085 io::stdin().read_line(&mut s)?;
1086 Ok(s.trim_end_matches(['\n', '\r']).to_string())
1087}
1088
1089#[cfg(test)]
1090mod tests {
1091 use super::*;
1092
1093 #[test]
1094 fn relative_time_breakpoints() {
1095 assert_eq!(relative_time(0), "0s ago");
1096 assert_eq!(relative_time(59), "59s ago");
1097 assert_eq!(relative_time(60), "1m ago");
1098 assert_eq!(relative_time(3599), "59m ago");
1099 assert_eq!(relative_time(3600), "1h ago");
1100 assert_eq!(relative_time(86_399), "23h ago");
1101 assert_eq!(relative_time(86_400), "1d ago");
1102 assert_eq!(relative_time(30 * 86_400), "1mo ago");
1103 assert_eq!(relative_time(365 * 86_400), "1y ago");
1104 }
1105
1106 #[test]
1107 fn picker_marks_current_and_appends_sentinel() {
1108 let entries = picker_entries(
1109 &["alpha".to_string(), "tsk".to_string()],
1110 "tsk",
1111 );
1112 assert_eq!(entries, vec![" alpha", "* tsk", "<new>"]);
1113 }
1114
1115 #[test]
1116 fn picker_includes_current_when_missing_from_list() {
1117 let entries = picker_entries(&[], "tsk");
1118 assert_eq!(entries, vec!["* tsk", "<new>"]);
1119 }
1120
1121 #[test]
1122 fn strip_marker_handles_all_prefixes() {
1123 assert_eq!(strip_picker_marker("* tsk"), "tsk");
1124 assert_eq!(strip_picker_marker(" alpha"), "alpha");
1125 assert_eq!(strip_picker_marker("<new>"), "<new>");
1126 }
1127}