A file-based task manager
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 63a9c740e197b91e8acebfad479bc4eab9d4cc48 871 lines 27 kB view raw
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}