A file-based task manager
1pub mod errors;
2mod fzf;
3mod namespace;
4mod object;
5mod properties;
6mod queue;
7mod task;
8mod workspace;
9
10use clap::{Args, CommandFactory, Parser, Subcommand};
11use clap_complete::{Shell, generate};
12use edit::edit as open_editor;
13use errors::Result;
14use std::env::current_dir;
15use std::io::{self, Read, Write};
16use std::path::PathBuf;
17use std::process::exit;
18use std::str::FromStr as _;
19use workspace::{Id, Task, TaskIdentifier, Workspace};
20
21fn default_dir() -> Result<PathBuf> {
22 Ok(current_dir()?)
23}
24
25fn parse_id(s: &str) -> std::result::Result<Id, &'static str> {
26 Id::from_str(s).map_err(|_| "Unable to parse tsk- ID")
27}
28
29#[derive(Parser)]
30#[command(version, about)]
31struct Cli {
32 /// Override the tsk root directory.
33 #[arg(short = 'C', env = "TSK_ROOT", value_name = "DIR")]
34 dir: Option<PathBuf>,
35 #[command(subcommand)]
36 command: Commands,
37}
38
39#[derive(Subcommand)]
40enum Commands {
41 /// Bootstrap user-local state in `<git-dir>/tsk/`. (Auto-created on first use.)
42 Init,
43 /// Create a new task and push it onto the active queue.
44 Push {
45 #[arg(short = 'e', default_value_t = false)]
46 edit: bool,
47 #[arg(short = 'b')]
48 body: Option<String>,
49 #[command(flatten)]
50 title: Title,
51 },
52 /// Create a new task and append it to the bottom of the active queue.
53 Append {
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 /// Print the active queue's stack (top-of-stack first).
62 List {
63 #[arg(short = 'a', default_value_t = false)]
64 all: bool,
65 #[arg(short = 'c', default_value_t = 10)]
66 count: usize,
67 #[arg(short = 'q', default_value_t = false)]
68 ids_only: bool,
69 },
70 /// Show a task by id.
71 Show {
72 /// Print xattr-style YAML front-matter for the task's properties.
73 #[arg(short = 'x', default_value_t = false)]
74 show_attrs: bool,
75 /// Skip the rich-text parser and print the raw bytes verbatim.
76 #[arg(short = 'R', default_value_t = false)]
77 raw: bool,
78 #[command(flatten)]
79 task_id: TaskId,
80 },
81 /// Open `$EDITOR` to modify a task.
82 Edit {
83 #[command(flatten)]
84 task_id: TaskId,
85 },
86 /// Drop a task (remove from queue + unbind human id, history retained).
87 Drop {
88 #[command(flatten)]
89 task_id: TaskId,
90 },
91 /// Swap the top two tasks.
92 Swap,
93 /// Rotate top 3: third → top.
94 Rot,
95 /// Reverse-rotate top 3: top → third.
96 Tor,
97 /// Move a task to the top of the stack.
98 Prioritize {
99 #[command(flatten)]
100 task_id: TaskId,
101 },
102 /// Move a task to the bottom of the stack.
103 Deprioritize {
104 #[command(flatten)]
105 task_id: TaskId,
106 },
107 /// Drop index entries whose stable ids no longer resolve.
108 Clean,
109 /// Run every known one-shot migration against the active workspace.
110 /// Currently: backfill `status=open` on tasks without a status property.
111 /// New migrations land here as they're added.
112 FixUp,
113 /// Print the commit history of a tsk ref. Newest commit first.
114 Log {
115 #[command(subcommand)]
116 target: LogTarget,
117 },
118 /// Print refspec/setup hints for `git push`/`git fetch` to include `refs/tsk/*`.
119 GitSetup {
120 /// Configure push/fetch refspecs on the named remote (default: origin).
121 #[arg(short = 'r')]
122 remote: Option<String>,
123 },
124 /// Push tsk refs to a git remote (default: origin).
125 GitPush {
126 remote: Option<String>,
127 },
128 /// Fetch tsk refs from a git remote (default: origin).
129 GitPull {
130 remote: Option<String>,
131 },
132 /// Share a task into another namespace (binds same stable id under that namespace's next human id).
133 Share {
134 target: String,
135 #[command(flatten)]
136 task_id: TaskId,
137 },
138 /// Move a task from the active queue's index into another queue's inbox.
139 Assign {
140 target: String,
141 #[command(flatten)]
142 task_id: TaskId,
143 /// Auto-push refs to this remote after assigning. Empty string skips. Default: origin.
144 #[arg(short = 'R')]
145 remote: Option<String>,
146 },
147 /// Pull a task from another queue's index (only allowed if its can-pull is true).
148 Pull {
149 source: String,
150 #[command(flatten)]
151 task_id: TaskId,
152 },
153 /// List inbox items pending in the active queue.
154 Inbox {
155 /// Auto-pull from this remote first. Empty string skips. Default: origin.
156 #[arg(short = 'R')]
157 remote: Option<String>,
158 },
159 /// Accept an inbox item by key (no key = first item).
160 Accept { key: Option<String> },
161 /// Reject an inbox item by key (no key = first item).
162 Reject {
163 key: Option<String>,
164 /// Auto-push refs to this remote after rejecting. Empty string skips. Default: origin.
165 #[arg(short = 'R')]
166 remote: Option<String>,
167 },
168 /// Get/set/find tasks by property. Properties are zero-or-more text values
169 /// stored as files in the task's tree object; each value is one line.
170 Prop {
171 #[command(subcommand)]
172 action: PropAction,
173 },
174 /// Manage namespaces.
175 Namespace {
176 #[command(subcommand)]
177 action: NamespaceAction,
178 },
179 /// Manage queues.
180 Queue {
181 #[command(subcommand)]
182 action: QueueAction,
183 },
184 /// Switch active namespace (shorthand). With no name, fzf-picks from
185 /// existing namespaces (plus a `<new>` sentinel for creating one on
186 /// the fly).
187 Switch { name: Option<String> },
188 /// Generate shell completion.
189 Completion {
190 #[arg(short = 's')]
191 shell: Shell,
192 },
193}
194
195#[derive(Subcommand)]
196enum LogTarget {
197 /// Edit history of a single task.
198 Task {
199 #[command(flatten)]
200 task_id: TaskId,
201 },
202 /// Edit history of a namespace tree (id assignments, drops, shares).
203 /// Defaults to the active namespace.
204 Namespace { name: Option<String> },
205}
206
207#[derive(Subcommand)]
208enum PropAction {
209 /// List all values for every property on a task.
210 List {
211 #[command(flatten)]
212 task_id: TaskId,
213 },
214 /// Append a value to a property on a task. Creates the property if absent.
215 Add {
216 #[command(flatten)]
217 task_id: TaskId,
218 key: String,
219 value: String,
220 },
221 /// Replace the entire value list for a property. With no values, removes the property.
222 Set {
223 #[command(flatten)]
224 task_id: TaskId,
225 key: String,
226 values: Vec<String>,
227 },
228 /// Remove a single value (or, with no value, the entire property).
229 Unset {
230 #[command(flatten)]
231 task_id: TaskId,
232 key: String,
233 value: Option<String>,
234 },
235 /// List every property key currently in use across the workspace.
236 Keys,
237 /// List distinct values seen for a property key.
238 Values { key: String },
239 /// Find every task in the active namespace whose `key` is set (and equals
240 /// `value`, if supplied). With both omitted, fzf-picks the key, then value.
241 Find {
242 key: Option<String>,
243 value: Option<String>,
244 },
245}
246
247#[derive(Subcommand)]
248enum NamespaceAction {
249 List,
250 Current,
251 /// Switch active namespace. With no name, fzf-picks from existing
252 /// namespaces (plus a `<new>` sentinel for creating one on the fly).
253 Switch { name: Option<String> },
254 /// List every task bound in a namespace (defaults to active),
255 /// regardless of which queue (if any) it's on. One row per id.
256 Tasks { name: Option<String> },
257}
258
259#[derive(Subcommand)]
260enum QueueAction {
261 List,
262 Current,
263 /// Create a new queue. By default `can-pull=false`; use `-p` to make it true.
264 Create {
265 name: String,
266 #[arg(short = 'p', default_value_t = false)]
267 can_pull: bool,
268 },
269 Switch { name: String },
270}
271
272#[derive(Args)]
273#[group(required = true, multiple = false)]
274struct Title {
275 #[arg(short, value_name = "TITLE")]
276 title: Option<String>,
277 #[arg(value_name = "TITLE")]
278 title_simple: Option<Vec<String>>,
279}
280
281#[derive(Args)]
282#[group(required = false, multiple = false)]
283struct TaskId {
284 #[arg(short = 't', value_name = "ID")]
285 id: Option<u32>,
286 #[arg(short = 'T', value_name = "TSK-ID", value_parser = parse_id)]
287 tsk_id: Option<Id>,
288 #[arg(short = 'r', value_name = "RELATIVE", default_value_t = 0)]
289 relative_id: u32,
290}
291
292impl From<TaskId> for TaskIdentifier {
293 fn from(v: TaskId) -> Self {
294 if let Some(id) = v.id.map(Id::from).or(v.tsk_id) {
295 TaskIdentifier::Id(id)
296 } else {
297 TaskIdentifier::Relative(v.relative_id)
298 }
299 }
300}
301
302fn effective_remote(supplied: Option<String>) -> Option<String> {
303 supplied
304 .map(|s| if s.is_empty() { None } else { Some(s) })
305 .unwrap_or_else(|| Some("origin".to_string()))
306}
307
308fn dispatch(cli: Cli) -> Result<()> {
309 let dir = match cli.dir {
310 Some(d) => d,
311 None => default_dir()?,
312 };
313 match cli.command {
314 Commands::Init => Workspace::init(dir),
315 Commands::Push { edit, body, title } => command_push(dir, edit, body, title, true),
316 Commands::Append { edit, body, title } => command_push(dir, edit, body, title, false),
317 Commands::List {
318 all,
319 count,
320 ids_only,
321 } => command_list(dir, all, count, ids_only),
322 Commands::Show {
323 task_id,
324 show_attrs,
325 raw,
326 } => command_show(dir, task_id, show_attrs, raw),
327 Commands::Edit { task_id } => command_edit(dir, task_id),
328 Commands::Drop { task_id } => command_drop(dir, task_id),
329 Commands::Swap => Workspace::from_path(dir)?.swap_top(),
330 Commands::Rot => Workspace::from_path(dir)?.rot(),
331 Commands::Tor => Workspace::from_path(dir)?.tor(),
332 Commands::Prioritize { task_id } => {
333 Workspace::from_path(dir)?.prioritize(task_id.into())
334 }
335 Commands::Deprioritize { task_id } => {
336 Workspace::from_path(dir)?.deprioritize(task_id.into())
337 }
338 Commands::Clean => Workspace::from_path(dir)?.clean(),
339 Commands::Log { target } => command_log(dir, target),
340 Commands::FixUp => {
341 let ws = Workspace::from_path(dir)?;
342 let n = ws.backfill_status()?;
343 println!("backfill-status: set status=open on {n} task(s)");
344 Ok(())
345 }
346 Commands::GitSetup { remote } => {
347 let r = remote.unwrap_or_else(|| "origin".to_string());
348 Workspace::from_path(dir)?.configure_git_remote_refspecs(&r)
349 }
350 Commands::GitPush { remote } => {
351 let r = remote.unwrap_or_else(|| "origin".to_string());
352 Workspace::from_path(dir)?.git_push(&r)
353 }
354 Commands::GitPull { remote } => {
355 let r = remote.unwrap_or_else(|| "origin".to_string());
356 Workspace::from_path(dir)?.git_pull(&r)
357 }
358 Commands::Share { target, task_id } => command_share(dir, target, task_id),
359 Commands::Assign {
360 target,
361 task_id,
362 remote,
363 } => command_assign(dir, target, task_id, remote),
364 Commands::Pull { source, task_id } => command_pull(dir, source, task_id),
365 Commands::Inbox { remote } => command_inbox(dir, remote),
366 Commands::Accept { key } => command_accept(dir, key),
367 Commands::Reject { key, remote } => command_reject(dir, key, remote),
368 Commands::Prop { action } => command_prop(dir, action),
369 Commands::Namespace { action } => command_namespace(dir, action),
370 Commands::Queue { action } => command_queue(dir, action),
371 Commands::Switch { name } => command_namespace_switch(dir, name),
372 Commands::Completion { shell } => {
373 generate(shell, &mut Cli::command(), "tsk", &mut io::stdout());
374 Ok(())
375 }
376 }
377}
378
379/// Parse the CLI from `std::env::args()` and execute. Returns the process
380/// exit code so callers (the `tsk` and `git-tsk` bins) can hand it to
381/// `std::process::exit`.
382pub fn run() -> i32 {
383 match dispatch(Cli::parse()) {
384 Ok(()) => 0,
385 Err(e) => {
386 eprintln!("{e}");
387 2
388 }
389 }
390}
391
392fn read_title_and_body(
393 edit: bool,
394 body: Option<String>,
395 title_arg: Title,
396) -> Result<(String, String)> {
397 let mut title = if let Some(t) = title_arg.title {
398 t
399 } else if let Some(ts) = title_arg.title_simple {
400 ts.join(" ")
401 } else {
402 String::new()
403 };
404 let mut body = if body.is_none() {
405 if let Some((first, rest)) = title.split_once('\n') {
406 let extracted = rest.to_string();
407 title = first.to_string();
408 extracted
409 } else {
410 String::new()
411 }
412 } else {
413 title = title.replace(['\n', '\r'], " ");
414 body.unwrap_or_default()
415 };
416 if body == "-" {
417 body.clear();
418 io::stdin().read_to_string(&mut body)?;
419 }
420 if edit {
421 let new_content = open_editor(format!("{title}\n\n{body}"))?;
422 if let Some((t, b)) = new_content.split_once('\n') {
423 title = t.to_string();
424 body = b.trim_start_matches('\n').to_string();
425 }
426 }
427 title = title.replace(['\n', '\r'], " ");
428 Ok((title, body))
429}
430
431fn command_push(
432 dir: PathBuf,
433 edit: bool,
434 body: Option<String>,
435 title: Title,
436 on_top: bool,
437) -> Result<()> {
438 let (title, body) = read_title_and_body(edit, body, title)?;
439 let ws = Workspace::from_path(dir)?;
440 let task = ws.new_task(title, body)?;
441 if on_top {
442 ws.push_task(task)
443 } else {
444 ws.append_task(task)
445 }
446}
447
448fn command_list(dir: PathBuf, all: bool, count: usize, ids_only: bool) -> Result<()> {
449 let ws = Workspace::from_path(dir)?;
450 let stack = ws.read_stack()?;
451 if stack.is_empty() {
452 println!("*No tasks*");
453 return Ok(());
454 }
455 for (i, entry) in stack.iter().enumerate() {
456 if !all && i >= count {
457 break;
458 }
459 if ids_only {
460 println!("{}", entry.id);
461 } else {
462 println!("{}\t{}", entry.id, entry.title);
463 }
464 }
465 Ok(())
466}
467
468fn command_show(dir: PathBuf, task_id: TaskId, show_attrs: bool, raw: bool) -> Result<()> {
469 let task = Workspace::from_path(dir)?.task(task_id.into())?;
470 if show_attrs && !task.attributes.is_empty() {
471 println!("---");
472 for (k, vs) in &task.attributes {
473 for v in vs {
474 println!("{k}: \"{v}\"");
475 }
476 }
477 println!("---");
478 }
479 let plain = task.to_string();
480 match (raw, task::parse(&plain)) {
481 (false, Some(parsed)) => {
482 // Re-attach the title — the parser is fed the body-side text and
483 // produces a styled body; the title is rendered as-is on top.
484 print!("{}", parsed.content);
485 }
486 _ => print!("{plain}"),
487 }
488 println!();
489 Ok(())
490}
491
492fn command_edit(dir: PathBuf, task_id: TaskId) -> Result<()> {
493 let ws = Workspace::from_path(dir)?;
494 let mut task = ws.task(task_id.into())?;
495 let new_content = open_editor(format!("{}\n\n{}", task.title.trim(), task.body.trim()))?;
496 if let Some((t, b)) = new_content.split_once('\n') {
497 task.title = t.replace(['\n', '\r'], " ");
498 task.body = b.trim_start_matches('\n').to_string();
499 ws.save_task(&task)?;
500 }
501 Ok(())
502}
503
504fn command_drop(dir: PathBuf, task_id: TaskId) -> Result<()> {
505 if let Some(id) = Workspace::from_path(dir)?.drop(task_id.into())? {
506 println!("Dropped {id}");
507 Ok(())
508 } else {
509 eprintln!("No task to drop.");
510 exit(1);
511 }
512}
513
514fn command_share(dir: PathBuf, target: String, task_id: TaskId) -> Result<()> {
515 let ws = Workspace::from_path(dir)?;
516 let h = ws.share(task_id.into(), &target)?;
517 println!("Shared as {target}/tsk-{h}");
518 Ok(())
519}
520
521fn command_assign(
522 dir: PathBuf,
523 target: String,
524 task_id: TaskId,
525 remote: Option<String>,
526) -> Result<()> {
527 let ws = Workspace::from_path(dir)?;
528 let key = ws.assign_to_queue(task_id.into(), &target)?;
529 println!("Assigned to {target} as {key}");
530 if let Some(r) = effective_remote(remote) {
531 let _ = ws.git_push(&r);
532 }
533 Ok(())
534}
535
536fn command_pull(dir: PathBuf, source: String, task_id: TaskId) -> Result<()> {
537 let ws = Workspace::from_path(dir)?;
538 // For pull, the task id is interpreted in the source queue's namespace
539 // mapping context. Simplification: require the caller to use -T <stable>
540 // form via human id in active namespace. For v1 we just resolve in
541 // active namespace; sharing first lets the user reference foreign tasks.
542 let id = ws.pull_from_queue(&source, task_id.into())?;
543 println!("Pulled {id}");
544 Ok(())
545}
546
547fn command_inbox(dir: PathBuf, remote: Option<String>) -> Result<()> {
548 let ws = Workspace::from_path(dir)?;
549 if let Some(r) = effective_remote(remote) {
550 let _ = ws.git_pull(&r);
551 }
552 let inbox = ws.list_inbox()?;
553 if inbox.is_empty() {
554 println!("*Empty*");
555 return Ok(());
556 }
557 for item in inbox {
558 println!("{}\tfrom {}\t{}", item.key, item.source_queue, item.title);
559 }
560 Ok(())
561}
562
563fn command_accept(dir: PathBuf, key: Option<String>) -> Result<()> {
564 let ws = Workspace::from_path(dir)?;
565 let key = match key {
566 Some(k) => k,
567 None => {
568 ws.list_inbox()?
569 .into_iter()
570 .next()
571 .ok_or_else(|| errors::Error::Parse("Inbox is empty".into()))?
572 .key
573 }
574 };
575 let id = ws.accept_inbox(&key)?;
576 println!("Accepted as {id}");
577 Ok(())
578}
579
580fn command_reject(dir: PathBuf, key: Option<String>, remote: Option<String>) -> Result<()> {
581 let ws = Workspace::from_path(dir)?;
582 let key = match key {
583 Some(k) => k,
584 None => {
585 ws.list_inbox()?
586 .into_iter()
587 .next()
588 .ok_or_else(|| errors::Error::Parse("Inbox is empty".into()))?
589 .key
590 }
591 };
592 ws.reject_inbox(&key)?;
593 if let Some((src, _)) = key.rsplit_once('-') {
594 println!("Rejected {key} (returned to '{src}' inbox)");
595 } else {
596 println!("Rejected {key}");
597 }
598 if let Some(r) = effective_remote(remote) {
599 let _ = ws.git_push(&r);
600 }
601 Ok(())
602}
603
604fn command_log(dir: PathBuf, target: LogTarget) -> Result<()> {
605 let ws = Workspace::from_path(dir)?;
606 let commits = match target {
607 LogTarget::Task { task_id } => ws.log_task(task_id.into())?,
608 LogTarget::Namespace { name } => {
609 ws.log_namespace(&name.unwrap_or_else(|| ws.namespace()))?
610 }
611 };
612 for c in commits {
613 // git-log --oneline-style: short oid, summary, then author + date below.
614 let short = &c.oid[..c.oid.len().min(8)];
615 println!("{short} {}", c.summary);
616 println!(" {} ({})", c.author, format_unix(c.timestamp));
617 }
618 Ok(())
619}
620
621fn format_unix(ts: i64) -> String {
622 let now = std::time::SystemTime::now()
623 .duration_since(std::time::UNIX_EPOCH)
624 .map(|d| d.as_secs() as i64)
625 .unwrap_or(0);
626 let delta = now - ts;
627 if delta < 0 {
628 return "in the future".to_string();
629 }
630 relative_time(delta as u64)
631}
632
633fn relative_time(secs: u64) -> String {
634 const M: u64 = 60;
635 const H: u64 = 60 * M;
636 const D: u64 = 24 * H;
637 if secs < M {
638 format!("{secs}s ago")
639 } else if secs < H {
640 format!("{}m ago", secs / M)
641 } else if secs < D {
642 format!("{}h ago", secs / H)
643 } else if secs < 30 * D {
644 format!("{}d ago", secs / D)
645 } else if secs < 365 * D {
646 format!("{}mo ago", secs / (30 * D))
647 } else {
648 format!("{}y ago", secs / (365 * D))
649 }
650}
651
652fn command_prop(dir: PathBuf, action: PropAction) -> Result<()> {
653 let ws = Workspace::from_path(dir)?;
654 match action {
655 PropAction::List { task_id } => {
656 let task = ws.task(task_id.into())?;
657 for (k, vs) in &task.attributes {
658 for v in vs {
659 println!("{k}\t{v}");
660 }
661 }
662 }
663 PropAction::Add {
664 task_id,
665 key,
666 value,
667 } => ws.add_property_value(task_id.into(), &key, &value)?,
668 PropAction::Set {
669 task_id,
670 key,
671 values,
672 } => ws.set_property(task_id.into(), &key, values)?,
673 PropAction::Unset {
674 task_id,
675 key,
676 value,
677 } => ws.unset_property(task_id.into(), &key, value.as_deref())?,
678 PropAction::Keys => {
679 for k in ws.property_keys()? {
680 println!("{k}");
681 }
682 }
683 PropAction::Values { key } => {
684 for v in ws.property_values(&key)? {
685 println!("{v}");
686 }
687 }
688 PropAction::Find { key, value } => {
689 let key = match key {
690 Some(k) => k,
691 None => fzf::select::<_, String, _>(
692 ws.property_keys()?,
693 ["--prompt=key> "],
694 )?
695 .ok_or_else(|| errors::Error::Parse("No key selected".into()))?,
696 };
697 let value = match value {
698 Some(v) if v == "<any>" => None,
699 Some(v) => Some(v),
700 None => {
701 let mut choices = ws.property_values(&key)?;
702 choices.insert(0, "<any>".to_string());
703 let picked = fzf::select::<_, String, _>(
704 choices,
705 ["--prompt=value> "],
706 )?
707 .ok_or_else(|| errors::Error::Parse("No value selected".into()))?;
708 if picked == "<any>" {
709 None
710 } else {
711 Some(picked)
712 }
713 }
714 };
715 for (id, _stable, title) in ws.find_by_property(&key, value.as_deref())? {
716 println!("{id}\t{title}");
717 }
718 }
719 }
720 Ok(())
721}
722
723fn command_namespace(dir: PathBuf, action: NamespaceAction) -> Result<()> {
724 let ws = Workspace::from_path(dir)?;
725 match action {
726 NamespaceAction::List => {
727 for n in ws.list_namespaces()? {
728 println!("{n}");
729 }
730 }
731 NamespaceAction::Current => println!("{}", ws.namespace()),
732 NamespaceAction::Switch { name } => return resolve_and_switch_namespace(&ws, name),
733 NamespaceAction::Tasks { name } => {
734 let target = name.unwrap_or_else(|| ws.namespace());
735 for entry in ws.list_namespace_tasks(&target)? {
736 println!("{}\t{}", entry.id, entry.title);
737 }
738 }
739 }
740 Ok(())
741}
742
743fn command_queue(dir: PathBuf, action: QueueAction) -> Result<()> {
744 let ws = Workspace::from_path(dir)?;
745 match action {
746 QueueAction::List => {
747 for n in ws.list_queues()? {
748 println!("{n}");
749 }
750 }
751 QueueAction::Current => println!("{}", ws.queue()),
752 QueueAction::Create { name, can_pull } => {
753 ws.create_queue(&name, Some(can_pull))?;
754 println!("Created queue '{name}' (can-pull={can_pull})");
755 }
756 QueueAction::Switch { name } => ws.switch_queue(&name)?,
757 }
758 Ok(())
759}
760
761const NEW_NS_SENTINEL: &str = "<new>";
762
763fn command_namespace_switch(dir: PathBuf, name: Option<String>) -> Result<()> {
764 let ws = Workspace::from_path(dir)?;
765 resolve_and_switch_namespace(&ws, name)
766}
767
768fn resolve_and_switch_namespace(ws: &Workspace, name: Option<String>) -> Result<()> {
769 let target = match name {
770 Some(n) => n,
771 None => pick_namespace(ws)?,
772 };
773 ws.switch_namespace(&target)?;
774 println!("Switched to namespace '{target}'");
775 Ok(())
776}
777
778fn pick_namespace(ws: &Workspace) -> Result<String> {
779 let cur = ws.namespace();
780 let existing = ws.list_namespaces()?;
781 let entries = namespace_picker_entries(&existing, &cur);
782 let picked = fzf::select::<_, String, _>(entries, ["--prompt=namespace> "])?
783 .ok_or_else(|| errors::Error::Parse("No namespace selected".into()))?;
784 let picked = strip_picker_marker(&picked);
785 if picked == NEW_NS_SENTINEL {
786 let name = prompt_line("New namespace name: ")?;
787 if name.is_empty() {
788 return Err(errors::Error::Parse("Empty namespace name".into()));
789 }
790 Ok(name)
791 } else {
792 Ok(picked.to_string())
793 }
794}
795
796/// Build the fzf input lines for namespace selection: every existing
797/// namespace (active marked with `* `, others with ` `) plus a trailing
798/// `<new>` sentinel for creating one on the fly. The active namespace is
799/// always present even when no refs have been written yet.
800fn namespace_picker_entries(existing: &[String], current: &str) -> Vec<String> {
801 let mut entries: Vec<String> = existing
802 .iter()
803 .map(|n| {
804 if n == current {
805 format!("* {n}")
806 } else {
807 format!(" {n}")
808 }
809 })
810 .collect();
811 if !existing.iter().any(|n| n == current) {
812 entries.insert(0, format!("* {current}"));
813 }
814 entries.push(NEW_NS_SENTINEL.to_string());
815 entries
816}
817
818fn strip_picker_marker(s: &str) -> &str {
819 s.strip_prefix("* ").or_else(|| s.strip_prefix(" ")).unwrap_or(s)
820}
821
822fn prompt_line(prompt: &str) -> Result<String> {
823 eprint!("{prompt}");
824 io::stderr().flush()?;
825 let mut s = String::new();
826 io::stdin().read_line(&mut s)?;
827 Ok(s.trim_end_matches(['\n', '\r']).to_string())
828}
829
830#[allow(dead_code)]
831fn _silence_unused(_w: &dyn Write, _t: Task) {}
832
833#[cfg(test)]
834mod tests {
835 use super::*;
836
837 #[test]
838 fn relative_time_breakpoints() {
839 assert_eq!(relative_time(0), "0s ago");
840 assert_eq!(relative_time(59), "59s ago");
841 assert_eq!(relative_time(60), "1m ago");
842 assert_eq!(relative_time(3599), "59m ago");
843 assert_eq!(relative_time(3600), "1h ago");
844 assert_eq!(relative_time(86_399), "23h ago");
845 assert_eq!(relative_time(86_400), "1d ago");
846 assert_eq!(relative_time(30 * 86_400), "1mo ago");
847 assert_eq!(relative_time(365 * 86_400), "1y ago");
848 }
849
850 #[test]
851 fn picker_marks_current_and_appends_sentinel() {
852 let entries = namespace_picker_entries(
853 &["alpha".to_string(), "tsk".to_string()],
854 "tsk",
855 );
856 assert_eq!(entries, vec![" alpha", "* tsk", "<new>"]);
857 }
858
859 #[test]
860 fn picker_includes_current_when_missing_from_list() {
861 let entries = namespace_picker_entries(&[], "tsk");
862 assert_eq!(entries, vec!["* tsk", "<new>"]);
863 }
864
865 #[test]
866 fn strip_marker_handles_all_prefixes() {
867 assert_eq!(strip_picker_marker("* tsk"), "tsk");
868 assert_eq!(strip_picker_marker(" alpha"), "alpha");
869 assert_eq!(strip_picker_marker("<new>"), "<new>");
870 }
871}