A file-based task manager
0
fork

Configure Feed

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

at noah/git-backend-2 1127 lines 38 kB view raw
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}