From 32e4fd0007a3dc706408a281a2f454a4a3dc4472 Mon Sep 17 00:00:00 2001 From: Levi Neely <141506390+lneely@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:29:09 +0200 Subject: [PATCH] Make ork a pure 9P client with friendly commands ork now operates solely as a 9P client against ork-server; all org intelligence lives in the server. Remove the local file-parsing command layer (commands.rs) and its org-ast/org-parser/orkmode-core/serde deps. Base verbs speak raw 9P: ls, read, write, rdwr. Address resolves from --addr, then $ORK_ADDR, then tcp://localhost:5640. Add friendly commands, all thin compositions of the base 9P operations: docs, sections, todos, agenda, get, set, add (--parent), mkdoc, reload. Fix the directory-listing parser in client.rs: the stat-entry name offset double-counted the size field (pos + 2 + 41 instead of pos + 41), so ls returned nothing. Handle a downstream reader closing early (e.g. | head) by treating BrokenPipe as a clean exit instead of panicking. --- Cargo.lock | 6 - crates/ork-cli/Cargo.toml | 6 - crates/ork-cli/src/client.rs | 11 +- crates/ork-cli/src/commands.rs | 1083 -------------------------------- crates/ork-cli/src/main.rs | 648 +++++++++---------- 5 files changed, 332 insertions(+), 1422 deletions(-) delete mode 100644 crates/ork-cli/src/commands.rs diff --git a/Cargo.lock b/Cargo.lock index 0dc7b84..b319a62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -550,12 +550,6 @@ name = "ork-cli" version = "0.1.0" dependencies = [ "clap", - "org-ast", - "org-parser", - "orkmode-core", - "serde", - "serde_json", - "thiserror", ] [[package]] diff --git a/crates/ork-cli/Cargo.toml b/crates/ork-cli/Cargo.toml index 807350a..2b0ff49 100644 --- a/crates/ork-cli/Cargo.toml +++ b/crates/ork-cli/Cargo.toml @@ -11,10 +11,4 @@ name = "ork" path = "src/main.rs" [dependencies] -org-ast = { path = "../org-ast" } -org-parser = { path = "../org-parser" } -orkmode-core = { path = "../orkmode-core" } clap = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -thiserror = { workspace = true } diff --git a/crates/ork-cli/src/client.rs b/crates/ork-cli/src/client.rs index bf09726..668f44d 100644 --- a/crates/ork-cli/src/client.rs +++ b/crates/ork-cli/src/client.rs @@ -233,12 +233,6 @@ impl Client { Ok(result) } - /// Read file as string. - pub fn read_string(&mut self, path: &str) -> io::Result { - let data = self.read(path)?; - Ok(String::from_utf8_lossy(&data).trim().to_string()) - } - /// Write to a file. pub fn write(&mut self, path: &str, data: &[u8]) -> io::Result<()> { let fid = self.walk(path)?; @@ -370,8 +364,9 @@ impl Client { break; } - // Skip to name field: size(2) + type(2) + dev(4) + qid(13) + mode(4) + atime(4) + mtime(4) + length(8) = 41 - let name_offset = pos + 2 + 41; + // Stat layout from entry start: size(2) + type(2) + dev(4) + qid(13) + // + mode(4) + atime(4) + mtime(4) + length(8) = 41 bytes, then name[s]. + let name_offset = pos + 41; if name_offset + 2 > data.len() { break; } diff --git a/crates/ork-cli/src/commands.rs b/crates/ork-cli/src/commands.rs deleted file mode 100644 index c58e65b..0000000 --- a/crates/ork-cli/src/commands.rs +++ /dev/null @@ -1,1083 +0,0 @@ -//! Command implementations for the ork CLI. - -use org_ast::{Document, Section, Timestamp, TimestampValue}; -use org_parser::Parser; -use serde::Serialize; -use std::collections::{HashMap, HashSet}; -use std::fs; -use std::path::Path; - -type Result = std::result::Result>; - -// === Timestamp Formatting === - -fn format_timestamp(ts: &Timestamp) -> String { - match &ts.value { - TimestampValue::Point(p) => { - let mut s = format!("{:04}-{:02}-{:02}", p.date.year, p.date.month, p.date.day); - if let Some(day_name) = &p.date.day_name { - s.push(' '); - s.push_str(day_name); - } - if let Some(time) = &p.time { - s.push_str(&format!(" {:02}:{:02}", time.hour, time.minute)); - } - if let Some(r) = &p.repeater { - let kind = match r.kind { - org_ast::RepeaterKind::Cumulative => "+", - org_ast::RepeaterKind::CatchUp => "++", - org_ast::RepeaterKind::Restart => ".+", - }; - let unit = match r.unit { - org_ast::TimeUnit::Hour => "h", - org_ast::TimeUnit::Day => "d", - org_ast::TimeUnit::Week => "w", - org_ast::TimeUnit::Month => "m", - org_ast::TimeUnit::Year => "y", - }; - s.push_str(&format!(" {}{}{}", kind, r.value, unit)); - } - s - } - TimestampValue::Range { start, end } => { - format!( - "{:04}-{:02}-{:02}--{:04}-{:02}-{:02}", - start.date.year, start.date.month, start.date.day, - end.date.year, end.date.month, end.date.day - ) - } - TimestampValue::Diary(sexp) => sexp.clone(), - } -} - -// === File Discovery === - -/// Find all .org files in a path (file or directory). -fn find_org_files(path: &Path, recursive: bool) -> Result> { - if path.is_file() { - if path.extension().map_or(false, |ext| ext == "org") { - return Ok(vec![path.to_path_buf()]); - } else { - return Err(format!("Not an org file: {}", path.display()).into()); - } - } - - if !path.is_dir() { - return Err(format!("Path not found: {}", path.display()).into()); - } - - let mut files = Vec::new(); - collect_org_files(path, recursive, &mut files)?; - files.sort(); - Ok(files) -} - -fn collect_org_files(dir: &Path, recursive: bool, files: &mut Vec) -> Result<()> { - for entry in fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - - if path.is_file() { - if path.extension().map_or(false, |ext| ext == "org") { - files.push(path); - } - } else if path.is_dir() && recursive { - // Skip hidden directories - if !path.file_name().map_or(false, |n| n.to_string_lossy().starts_with('.')) { - collect_org_files(&path, recursive, files)?; - } - } - } - Ok(()) -} - -/// Parsed file with metadata. -struct ParsedFile { - path: std::path::PathBuf, - doc: Document, -} - -/// Parse all org files in a path. -fn parse_files(path: &Path, recursive: bool) -> Result> { - let files = find_org_files(path, recursive)?; - let mut parsed = Vec::new(); - let mut parser = Parser::new(); - - for file_path in files { - let content = fs::read_to_string(&file_path)?; - match parser.parse(&content) { - Ok(doc) => parsed.push(ParsedFile { - path: file_path, - doc, - }), - Err(e) => { - eprintln!("warning: failed to parse {}: {}", file_path.display(), e); - } - } - } - - Ok(parsed) -} - -// === JSON Types === - -#[derive(Serialize)] -struct SectionInfo { - file: String, - level: u8, - title: String, - #[serde(skip_serializing_if = "Option::is_none")] - todo: Option, - #[serde(skip_serializing_if = "Option::is_none")] - done: Option, - #[serde(skip_serializing_if = "Option::is_none")] - priority: Option, - #[serde(skip_serializing_if = "Vec::is_empty")] - tags: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - custom_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - id: Option, -} - -impl SectionInfo { - fn from_section(section: &Section, file: &Path) -> Self { - Self { - file: file.display().to_string(), - level: section.headline.level, - title: section.headline.title_text(), - todo: section.headline.keyword.as_ref().map(|k| k.keyword.clone()), - done: section.headline.keyword.as_ref().map(|k| k.done), - priority: section.headline.priority.map(|p| p.as_char()), - tags: section.headline.tags.clone(), - custom_id: section.custom_id().map(|s| s.to_string()), - id: section.id().map(|s| s.to_string()), - } - } -} - -#[derive(Serialize)] -struct FileInfo { - file: String, - #[serde(skip_serializing_if = "Option::is_none")] - title: Option, - sections: usize, - todos: usize, - done: usize, -} - -#[derive(Serialize)] -struct AggregateInfo { - files: usize, - total_sections: usize, - total_todos: usize, - total_done: usize, - tags: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - file_details: Vec, -} - -// === Commands === - -pub fn info(path: &Path, json: bool, recursive: bool) -> Result<()> { - let parsed = parse_files(path, recursive)?; - - if parsed.is_empty() { - if json { - println!("{{\"files\": 0, \"total_sections\": 0, \"total_todos\": 0, \"total_done\": 0, \"tags\": []}}"); - } else { - println!("No org files found."); - } - return Ok(()); - } - - let mut all_tags: HashSet = HashSet::new(); - let mut file_details = Vec::new(); - let mut total_sections = 0; - let mut total_todos = 0; - let mut total_done = 0; - - for pf in &parsed { - let sections = pf.doc.all_sections().count(); - let todos = pf.doc.todos().count(); - let done = pf.doc.all_sections().filter(|s| s.is_done()).count(); - - total_sections += sections; - total_todos += todos; - total_done += done; - - for section in pf.doc.all_sections() { - for tag in §ion.headline.tags { - all_tags.insert(tag.clone()); - } - } - - file_details.push(FileInfo { - file: pf.path.display().to_string(), - title: pf.doc.title().map(|s| s.to_string()), - sections, - todos, - done, - }); - } - - let info = AggregateInfo { - files: parsed.len(), - total_sections, - total_todos, - total_done, - tags: all_tags.into_iter().collect(), - file_details, - }; - - if json { - println!("{}", serde_json::to_string_pretty(&info)?); - } else { - println!("Files: {}", info.files); - println!("Sections: {}", info.total_sections); - println!("TODOs: {} open, {} done", info.total_todos, info.total_done); - if !info.tags.is_empty() { - let mut tags: Vec<_> = info.tags.iter().collect(); - tags.sort(); - println!("Tags: {}", tags.into_iter().cloned().collect::>().join(", ")); - } - println!(); - for fd in &info.file_details { - let title = fd.title.as_deref().unwrap_or("-"); - println!(" {} ({} sections, {} TODO) - {}", fd.file, fd.sections, fd.todos, title); - } - } - - Ok(()) -} - -pub fn sections( - path: &Path, - json: bool, - tag_filter: Option, - todo_filter: Option, - max_depth: Option, - todos_only: bool, - recursive: bool, -) -> Result<()> { - let parsed = parse_files(path, recursive)?; - - let mut all_sections: Vec = Vec::new(); - - for pf in &parsed { - for section in pf.doc.all_sections() { - // Depth filter - if let Some(max) = max_depth { - if section.level() > max { - continue; - } - } - // Tag filter - if let Some(ref tag) = tag_filter { - if !section.has_tag(tag) { - continue; - } - } - // TODO filter - if let Some(ref todo) = todo_filter { - match section.headline.keyword.as_ref() { - Some(kw) if kw.keyword == *todo => {} - _ => continue, - } - } - // TODOs only - if todos_only && !section.is_todo() { - continue; - } - - all_sections.push(SectionInfo::from_section(section, &pf.path)); - } - } - - if json { - println!("{}", serde_json::to_string_pretty(&all_sections)?); - } else { - if all_sections.is_empty() { - println!("No matching sections found."); - } else { - let mut current_file = String::new(); - for si in &all_sections { - if si.file != current_file { - if !current_file.is_empty() { - println!(); - } - println!("# {}", si.file); - current_file = si.file.clone(); - } - - let indent = " ".repeat(si.level as usize - 1); - let keyword = si.todo.as_ref().map(|k| format!("{} ", k)).unwrap_or_default(); - let priority = si.priority.map(|p| format!("[#{}] ", p)).unwrap_or_default(); - let tags = if si.tags.is_empty() { - String::new() - } else { - format!(" :{}:", si.tags.join(":")) - }; - - println!( - "{}{} {}{}{}{}", - indent, - "*".repeat(si.level as usize), - keyword, - priority, - si.title, - tags, - ); - } - } - } - - Ok(()) -} - -pub fn tags(path: &Path, json: bool, recursive: bool) -> Result<()> { - let parsed = parse_files(path, recursive)?; - - let mut tag_counts: HashMap = HashMap::new(); - - for pf in &parsed { - for section in pf.doc.all_sections() { - for tag in §ion.headline.tags { - *tag_counts.entry(tag.clone()).or_insert(0) += 1; - } - } - for tag in &pf.doc.settings.filetags { - tag_counts.entry(tag.clone()).or_insert(0); - } - } - - if json { - println!("{}", serde_json::to_string_pretty(&tag_counts)?); - } else { - let mut tags: Vec<_> = tag_counts.into_iter().collect(); - tags.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); - for (tag, count) in tags { - println!("{}: {}", tag, count); - } - } - - Ok(()) -} - -pub fn query(path: &Path, query: &str, json: bool, recursive: bool) -> Result<()> { - let parsed = parse_files(path, recursive)?; - - // Parse query: "tag:work", "todo:TODO", "priority:A", "title:pattern", "file:pattern" - let (key, value) = query - .split_once(':') - .ok_or_else(|| format!("Invalid query format: {}. Use key:value", query))?; - - let mut matches: Vec = Vec::new(); - - for pf in &parsed { - // File filter - if key == "file" { - let file_name = pf.path.file_name() - .map(|n| n.to_string_lossy().to_lowercase()) - .unwrap_or_default(); - if !file_name.contains(&value.to_lowercase()) { - continue; - } - } - - for section in pf.doc.all_sections() { - let matched = match key { - "tag" => section.has_tag(value), - "todo" => section.headline.keyword.as_ref().map_or(false, |k| k.keyword == value), - "priority" => section.headline.priority.map_or(false, |p| p.as_char().to_string() == value), - "title" => section.headline.title_text().to_lowercase().contains(&value.to_lowercase()), - "id" => section.custom_id() == Some(value) || section.id() == Some(value), - "done" => { - let is_done = section.is_done(); - (value == "true" && is_done) || (value == "false" && !is_done) - } - "file" => true, // Already filtered above - _ => false, - }; - - if matched { - matches.push(SectionInfo::from_section(section, &pf.path)); - } - } - } - - if json { - println!("{}", serde_json::to_string_pretty(&matches)?); - } else { - println!("Found {} matches:", matches.len()); - for m in &matches { - let keyword = m.todo.as_ref().map(|k| format!("{} ", k)).unwrap_or_default(); - println!(" [{}] {} {}{}", m.file, "*".repeat(m.level as usize), keyword, m.title); - } - } - - Ok(()) -} - -pub fn get(path: &Path, id: &str, json: bool, _include_body: bool, recursive: bool) -> Result<()> { - let parsed = parse_files(path, recursive)?; - - // Find section by custom_id, id, or title - for pf in &parsed { - if let Some(section) = pf.doc.all_sections().find(|s| { - s.custom_id() == Some(id) - || s.id() == Some(id) - || s.headline.title_text().to_lowercase() == id.to_lowercase() - }) { - if json { - let info = SectionInfo::from_section(section, &pf.path); - println!("{}", serde_json::to_string_pretty(&info)?); - } else { - println!("# {}", pf.path.display()); - let keyword = section.headline.keyword.as_ref() - .map(|k| format!("{} ", k.keyword)) - .unwrap_or_default(); - let priority = section.headline.priority - .map(|p| format!("[#{}] ", p.as_char())) - .unwrap_or_default(); - let tags = if section.headline.tags.is_empty() { - String::new() - } else { - format!(" :{}:", section.headline.tags.join(":")) - }; - - println!( - "{} {}{}{}{}", - "*".repeat(section.level() as usize), - keyword, - priority, - section.headline.title_text(), - tags, - ); - - if let Some(props) = §ion.properties { - println!(":PROPERTIES:"); - for prop in &props.properties { - println!(":{}: {}", prop.key, prop.value); - } - println!(":END:"); - } - } - return Ok(()); - } - } - - Err(format!("Section not found: {}", id).into()) -} - -pub fn export(path: &Path, section_id: Option<&str>, pretty: bool, recursive: bool) -> Result<()> { - let parsed = parse_files(path, recursive)?; - - if let Some(id) = section_id { - // Export specific section - for pf in &parsed { - if let Some(section) = pf.doc.all_sections().find(|s| { - s.custom_id() == Some(id) || s.id() == Some(id) - }) { - let output = if pretty { - serde_json::to_string_pretty(section)? - } else { - serde_json::to_string(section)? - }; - println!("{}", output); - return Ok(()); - } - } - return Err(format!("Section not found: {}", id).into()); - } - - // Export all documents - #[derive(Serialize)] - struct ExportedDoc { - file: String, - document: Document, - } - - let exported: Vec = parsed - .into_iter() - .map(|pf| ExportedDoc { - file: pf.path.display().to_string(), - document: pf.doc, - }) - .collect(); - - let output = if pretty { - serde_json::to_string_pretty(&exported)? - } else { - serde_json::to_string(&exported)? - }; - println!("{}", output); - - Ok(()) -} - -pub fn toggle(path: &Path, id: &str, dry_run: bool, recursive: bool) -> Result<()> { - let files = find_org_files(path, recursive)?; - let mut parser = Parser::new(); - - for file_path in &files { - let content = fs::read_to_string(file_path)?; - let doc = match parser.parse(&content) { - Ok(d) => d, - Err(_) => continue, - }; - - // Find the section - let section = match doc.all_sections().find(|s| { - s.custom_id() == Some(id) - || s.id() == Some(id) - || s.headline.title_text().to_lowercase() == id.to_lowercase() - }) { - Some(s) => s, - None => continue, - }; - - let current_keyword = section.headline.keyword.as_ref().map(|k| k.keyword.as_str()); - - // Determine next state - let (next_keyword, action) = if let Some(kw) = current_keyword { - if doc.is_done_keyword(kw) { - (None, format!("Remove {}", kw)) - } else { - let done_kw = doc.settings.todo_keywords.iter() - .flat_map(|set| set.done.first()) - .next() - .map(|s| s.as_str()) - .unwrap_or("DONE"); - (Some(done_kw), format!("{} -> {}", kw, done_kw)) - } - } else { - let todo_kw = doc.settings.todo_keywords.iter() - .flat_map(|set| set.active.first()) - .next() - .map(|s| s.as_str()) - .unwrap_or("TODO"); - (Some(todo_kw), format!("(none) -> {}", todo_kw)) - }; - - println!("[{}] Toggle: {}", file_path.display(), action); - - if dry_run { - println!("(dry run, no changes made)"); - return Ok(()); - } - - // Perform the edit - let span = section.headline.span; - let headline_start = span.start.offset; - let stars_end = headline_start + section.headline.level as usize + 1; - let headline_text = &content[stars_end..span.end.offset]; - - let mut new_headline = "*".repeat(section.headline.level as usize); - new_headline.push(' '); - - if let Some(kw) = next_keyword { - new_headline.push_str(kw); - new_headline.push(' '); - } - - let title_start = if let Some(old_kw) = current_keyword { - if headline_text.starts_with(old_kw) { - old_kw.len() + 1 - } else { - 0 - } - } else { - 0 - }; - - new_headline.push_str(&headline_text[title_start..]); - - let mut new_content = String::new(); - new_content.push_str(&content[..headline_start]); - new_content.push_str(&new_headline); - new_content.push_str(&content[span.end.offset..]); - - fs::write(file_path, new_content)?; - println!("Updated {}", file_path.display()); - return Ok(()); - } - - Err(format!("Section not found: {}", id).into()) -} - -pub fn add( - path: &Path, - target_file: Option<&Path>, - level: u8, - title: &str, - todo: Option<&str>, - priority: Option, - tags: Option<&str>, - body: Option<&str>, - _after: Option<&str>, - _parent: Option<&str>, - dry_run: bool, -) -> Result<()> { - // Determine target file - let file_path = if path.is_file() { - path.to_path_buf() - } else if let Some(f) = target_file { - if f.is_absolute() { - f.to_path_buf() - } else { - path.join(f) - } - } else { - return Err("For directory paths, specify target file with --file".into()); - }; - - // Build the headline - let mut headline = "*".repeat(level as usize); - headline.push(' '); - - if let Some(kw) = todo { - headline.push_str(kw); - headline.push(' '); - } - - if let Some(p) = priority { - headline.push_str(&format!("[#{}] ", p)); - } - - headline.push_str(title); - - if let Some(tag_str) = tags { - let tag_list: Vec<_> = tag_str.split(',').map(|s| s.trim()).collect(); - headline.push_str(&format!(" :{}:", tag_list.join(":"))); - } - - headline.push('\n'); - - if let Some(b) = body { - headline.push_str(b); - if !b.ends_with('\n') { - headline.push('\n'); - } - } - - println!("Adding to {}:", file_path.display()); - print!("{}", headline); - - if dry_run { - println!("(dry run, no changes made)"); - return Ok(()); - } - - // Create file if it doesn't exist, otherwise append - let mut content = if file_path.exists() { - fs::read_to_string(&file_path)? - } else { - String::new() - }; - - if !content.is_empty() && !content.ends_with('\n') { - content.push('\n'); - } - content.push_str(&headline); - fs::write(&file_path, content)?; - - println!("Added to {}", file_path.display()); - Ok(()) -} - -pub fn property( - path: &Path, - id: &str, - name: &str, - value: Option<&str>, - dry_run: bool, - recursive: bool, -) -> Result<()> { - let files = find_org_files(path, recursive)?; - let mut parser = Parser::new(); - - for file_path in &files { - let content = fs::read_to_string(file_path)?; - let doc = match parser.parse(&content) { - Ok(d) => d, - Err(_) => continue, - }; - - // Find the section - let section = match doc.all_sections().find(|s| { - s.custom_id() == Some(id) - || s.id() == Some(id) - || s.headline.title_text().to_lowercase() == id.to_lowercase() - }) { - Some(s) => s, - None => continue, - }; - - let action = if let Some(v) = value { - format!("Set :{}: {}", name, v) - } else { - format!("Delete :{}", name) - }; - println!("[{}] {} in '{}'", file_path.display(), action, section.headline.title_text()); - - if dry_run { - println!("(dry run, no changes made)"); - return Ok(()); - } - - let new_content = if let Some(props) = §ion.properties { - // Property drawer exists - let drawer_content = &content[props.span.start.offset..props.span.end.offset]; - - // Check if property already exists - let prop_pattern = format!(":{}: ", name.to_uppercase()); - let prop_pattern_lower = format!(":{}: ", name); - - if let Some(v) = value { - // Set property - if drawer_content.contains(&prop_pattern) || drawer_content.contains(&prop_pattern_lower) { - // Update existing property - let mut new_drawer = String::new(); - for line in drawer_content.lines() { - if line.trim_start().to_uppercase().starts_with(&format!(":{}:", name.to_uppercase())) { - new_drawer.push_str(&format!(":{}: {}\n", name.to_uppercase(), v)); - } else { - new_drawer.push_str(line); - new_drawer.push('\n'); - } - } - format!( - "{}{}{}", - &content[..props.span.start.offset], - new_drawer.trim_end(), - &content[props.span.end.offset..] - ) - } else { - // Add new property before :END: - let end_pos = drawer_content.rfind(":END:").unwrap_or(drawer_content.len()); - let insert_pos = props.span.start.offset + end_pos; - format!( - "{}:{}: {}\n{}", - &content[..insert_pos], - name.to_uppercase(), - v, - &content[insert_pos..] - ) - } - } else { - // Delete property - let mut new_drawer = String::new(); - for line in drawer_content.lines() { - if !line.trim_start().to_uppercase().starts_with(&format!(":{}:", name.to_uppercase())) { - new_drawer.push_str(line); - new_drawer.push('\n'); - } - } - format!( - "{}{}{}", - &content[..props.span.start.offset], - new_drawer.trim_end(), - &content[props.span.end.offset..] - ) - } - } else if let Some(v) = value { - // No property drawer exists, create one - // Insert after headline (and planning line if present) - let insert_pos = if let Some(planning) = §ion.planning { - planning.span.end.offset - } else { - section.headline.span.end.offset - }; - - let drawer = format!("\n:PROPERTIES:\n:{}: {}\n:END:", name.to_uppercase(), v); - format!( - "{}{}{}", - &content[..insert_pos], - drawer, - &content[insert_pos..] - ) - } else { - // No drawer and deleting — nothing to do - println!("Property '{}' not found", name); - return Ok(()); - }; - - fs::write(file_path, new_content)?; - println!("Updated {}", file_path.display()); - return Ok(()); - } - - Err(format!("Section not found: {}", id).into()) -} - -pub fn tag( - path: &Path, - id: &str, - tag: &str, - remove: bool, - dry_run: bool, - recursive: bool, -) -> Result<()> { - let files = find_org_files(path, recursive)?; - let mut parser = Parser::new(); - - for file_path in &files { - let content = fs::read_to_string(file_path)?; - let doc = match parser.parse(&content) { - Ok(d) => d, - Err(_) => continue, - }; - - // Find the section - let section = match doc.all_sections().find(|s| { - s.custom_id() == Some(id) - || s.id() == Some(id) - || s.headline.title_text().to_lowercase() == id.to_lowercase() - }) { - Some(s) => s, - None => continue, - }; - - let has_tag = section.headline.tags.iter().any(|t| t.eq_ignore_ascii_case(tag)); - - if remove && !has_tag { - println!("Tag '{}' not found on '{}'", tag, section.headline.title_text()); - return Ok(()); - } - if !remove && has_tag { - println!("Tag '{}' already exists on '{}'", tag, section.headline.title_text()); - return Ok(()); - } - - let action = if remove { "Remove" } else { "Add" }; - println!("[{}] {} tag '{}' on '{}'", file_path.display(), action, tag, section.headline.title_text()); - - if dry_run { - println!("(dry run, no changes made)"); - return Ok(()); - } - - // Rebuild headline with new tags - let headline_text = &content[section.headline.span.start.offset..section.headline.span.end.offset]; - - // Parse the headline to rebuild it - let mut new_tags: Vec = section.headline.tags.clone(); - if remove { - new_tags.retain(|t| !t.eq_ignore_ascii_case(tag)); - } else { - new_tags.push(tag.to_string()); - } - - // Build new headline - let mut new_headline = "*".repeat(section.headline.level as usize); - new_headline.push(' '); - - if let Some(kw) = §ion.headline.keyword { - new_headline.push_str(&kw.keyword); - new_headline.push(' '); - } - - if let Some(p) = section.headline.priority { - new_headline.push_str(&format!("[#{}] ", p.as_char())); - } - - new_headline.push_str(§ion.headline.title_text()); - - if !new_tags.is_empty() { - // Pad to column 77 or add single space - let current_len = new_headline.len(); - let tag_str = format!(":{}:", new_tags.join(":")); - let target_col = 77; - if current_len + 1 + tag_str.len() < target_col { - let padding = target_col - current_len - tag_str.len(); - new_headline.push_str(&" ".repeat(padding)); - } else { - new_headline.push(' '); - } - new_headline.push_str(&tag_str); - } - - // Check if original had newline - let has_newline = headline_text.ends_with('\n'); - if has_newline { - new_headline.push('\n'); - } - - let new_content = format!( - "{}{}{}", - &content[..section.headline.span.start.offset], - new_headline, - &content[section.headline.span.end.offset..] - ); - - fs::write(file_path, new_content)?; - println!("Updated {}", file_path.display()); - return Ok(()); - } - - Err(format!("Section not found: {}", id).into()) -} - -pub fn check(path: &Path, json: bool, recursive: bool) -> Result<()> { - let files = find_org_files(path, recursive)?; - let mut parser = Parser::new(); - - #[derive(Serialize)] - struct FileCheck { - file: String, - valid: bool, - sections: usize, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, - } - - let mut results = Vec::new(); - let mut all_valid = true; - - for file_path in &files { - let content = fs::read_to_string(file_path)?; - match parser.parse(&content) { - Ok(doc) => { - results.push(FileCheck { - file: file_path.display().to_string(), - valid: true, - sections: doc.all_sections().count(), - error: None, - }); - } - Err(e) => { - all_valid = false; - results.push(FileCheck { - file: file_path.display().to_string(), - valid: false, - sections: 0, - error: Some(e.to_string()), - }); - } - } - } - - if json { - #[derive(Serialize)] - struct CheckResult { - all_valid: bool, - files: Vec, - } - let result = CheckResult { all_valid, files: results }; - println!("{}", serde_json::to_string_pretty(&result)?); - } else { - for r in &results { - if r.valid { - println!("✓ {} ({} sections)", r.file, r.sections); - } else { - println!("✗ {} - {}", r.file, r.error.as_deref().unwrap_or("unknown error")); - } - } - if all_valid { - println!("\nAll {} files valid.", results.len()); - } else { - println!("\nSome files have errors."); - } - } - - Ok(()) -} - -pub fn agenda(path: &Path, _days: u32, json: bool, include_done: bool, recursive: bool) -> Result<()> { - let parsed = parse_files(path, recursive)?; - - #[derive(Serialize)] - struct AgendaItem { - file: String, - title: String, - todo: Option, - done: bool, - #[serde(skip_serializing_if = "Option::is_none")] - deadline: Option, - #[serde(skip_serializing_if = "Option::is_none")] - scheduled: Option, - tags: Vec, - } - - let mut items = Vec::new(); - - for pf in &parsed { - let file_name = pf.path.display().to_string(); - - for section in pf.doc.all_sections() { - if section.is_done() && !include_done { - continue; - } - - let has_planning = section.planning.is_some(); - let has_todo = section.headline.keyword.is_some(); - - if !has_planning && !has_todo { - continue; - } - - let planning = section.planning.as_ref(); - - items.push(AgendaItem { - file: file_name.clone(), - title: section.headline.title_text(), - todo: section.headline.keyword.as_ref().map(|k| k.keyword.clone()), - done: section.is_done(), - deadline: planning.and_then(|p| p.deadline.as_ref().map(format_timestamp)), - scheduled: planning.and_then(|p| p.scheduled.as_ref().map(format_timestamp)), - tags: section.headline.tags.clone(), - }); - } - } - - if json { - println!("{}", serde_json::to_string_pretty(&items)?); - } else { - if items.is_empty() { - println!("No agenda items found."); - } else { - for item in &items { - let todo = item.todo.as_deref().unwrap_or(""); - let status = if item.done { "✓" } else { "○" }; - let tags = if item.tags.is_empty() { - String::new() - } else { - format!(" :{}:", item.tags.join(":")) - }; - let mut dates = Vec::new(); - if let Some(d) = &item.deadline { - dates.push(format!("D:{}", d)); - } - if let Some(s) = &item.scheduled { - dates.push(format!("S:{}", s)); - } - let dates_str = if dates.is_empty() { String::new() } else { format!(" [{}]", dates.join(" ")) }; - - println!("{} {} {}{}{} ({})", status, todo, item.title, tags, dates_str, item.file); - } - } - } - - Ok(()) -} - -pub fn files(path: &Path, json: bool, recursive: bool) -> Result<()> { - let files = find_org_files(path, recursive)?; - - if json { - let file_list: Vec = files.iter().map(|f| f.display().to_string()).collect(); - println!("{}", serde_json::to_string_pretty(&file_list)?); - } else { - if files.is_empty() { - println!("No org files found."); - } else { - for f in &files { - println!("{}", f.display()); - } - println!("\n{} file(s)", files.len()); - } - } - - Ok(()) -} diff --git a/crates/ork-cli/src/main.rs b/crates/ork-cli/src/main.rs index 7f8dbec..22f619f 100644 --- a/crates/ork-cli/src/main.rs +++ b/crates/ork-cli/src/main.rs @@ -1,353 +1,363 @@ -//! ork - CLI for org-mode file manipulation +//! ork - 9P client for ork-server. //! -//! Operates on org directories or individual files. -//! Provides commands for: -//! - Querying org files (list todos, sections, tags) -//! - Outputting as JSON for programmatic use -//! - Modifying org files (add/update sections, toggle todos) +//! `ork` is only a 9P client: it connects to a running `ork-server` and +//! operates on its namespace. All org-mode intelligence lives in the server. +//! The base verbs (ls/read/write/rdwr) speak raw 9P; the friendly verbs +//! (docs/sections/todos/agenda/get/set/add/mkdoc/reload) are thin compositions +//! of those same 9P operations against the exposed namespace. +//! +//! Address resolution: `--addr` flag, then `$ORK_ADDR`, then +//! `tcp://localhost:5640`. Accepts `tcp://host:port` or `unix:///path/to/sock`. use clap::{Parser, Subcommand}; -use std::path::PathBuf; +use std::io::{self, Read, Write}; +use std::process; -mod commands; +mod client; + +use client::Client; #[derive(Parser)] #[command(name = "ork")] -#[command(author, version, about = "CLI for org-mode file manipulation")] +#[command(author, version, about = "9P client for ork-server")] #[command(propagate_version = true)] -#[command(after_help = "PATH can be a directory (operates on all .org files) or a single .org file.")] +#[command(after_help = "\ +PATH is a path in the ork-server 9P namespace (e.g. /idx, /test/task/keyword). + +Address resolution order: --addr, then $ORK_ADDR, then tcp://localhost:5640. +Accepts tcp://host:port or unix:///path/to/socket.")] struct Cli { + /// Server address (default: $ORK_ADDR or tcp://localhost:5640) + #[arg(long, short, global = true)] + addr: Option, + #[command(subcommand)] command: Commands, } #[derive(Subcommand)] enum Commands { - /// Parse and display document information - Info { - /// Path to org directory or file - #[arg(default_value = ".")] - path: PathBuf, - /// Output as JSON - #[arg(long, short)] - json: bool, - /// Recurse into subdirectories - #[arg(long, short = 'R')] - recursive: bool, + /// List a directory in the namespace + Ls { + /// Namespace path (default: /) + #[arg(default_value = "/")] + path: String, }, - /// List sections/headlines + /// Read a file from the namespace to stdout + Read { + /// Namespace path + path: String, + }, + + /// Write to a file (DATA argument, or stdin if omitted) + Write { + /// Namespace path + path: String, + /// Data to write; if omitted, read from stdin + data: Option, + }, + + /// Write then read a request/response file (e.g. ctl, new) + Rdwr { + /// Namespace path + path: String, + /// Data to write; if omitted, read from stdin + data: Option, + }, + + /// List documents on the server + Docs, + + /// List top-level sections of a document Sections { - /// Path to org directory or file - #[arg(default_value = ".")] - path: PathBuf, - /// Output as JSON - #[arg(long, short)] - json: bool, - /// Filter by tag - #[arg(long, short)] - tag: Option, - /// Filter by TODO state (TODO, DONE, or custom) - #[arg(long)] - todo: Option, - /// Maximum depth to display (default: all) - #[arg(long, short)] - depth: Option, - /// Only show TODO items - #[arg(long)] - todos_only: bool, - /// Recurse into subdirectories - #[arg(long, short = 'R')] - recursive: bool, + /// Document name (as shown by `docs`) + doc: String, }, - /// List all tags across files - Tags { - /// Path to org directory or file - #[arg(default_value = ".")] - path: PathBuf, - /// Output as JSON - #[arg(long, short)] - json: bool, - /// Recurse into subdirectories - #[arg(long, short = 'R')] - recursive: bool, + /// List TODO items across all documents + Todos { + /// Only show TODOs from this document + doc: Option, }, - /// Query sections by various criteria - Query { - /// Query expression (tag:work, todo:TODO, priority:A, file:pattern, etc.) - query: String, - /// Path to org directory or file - #[arg(default_value = ".")] - path: PathBuf, - /// Output as JSON - #[arg(long, short)] - json: bool, - /// Recurse into subdirectories - #[arg(long, short = 'R')] - recursive: bool, - }, - - /// Get a specific section by ID - Get { - /// Section identifier (CUSTOM_ID, ID, or headline text) - id: String, - /// Path to org directory or file - #[arg(default_value = ".")] - path: PathBuf, - /// Output as JSON - #[arg(long, short)] - json: bool, - /// Include body content - #[arg(long, short)] - body: bool, - /// Recurse into subdirectories - #[arg(long, short = 'R')] - recursive: bool, - }, - - /// Export as JSON - Export { - /// Path to org directory or file - #[arg(default_value = ".")] - path: PathBuf, - /// Section ID to export (default: all) - #[arg(long)] - section: Option, - /// Pretty print JSON - #[arg(long, short)] - pretty: bool, - /// Recurse into subdirectories - #[arg(long, short = 'R')] - recursive: bool, - }, - - /// Toggle TODO state of a section - Toggle { - /// Section identifier (searches all files) - id: String, - /// Path to org directory or file - #[arg(default_value = ".")] - path: PathBuf, - /// Dry run - show what would change - #[arg(long, short = 'n')] - dry_run: bool, - /// Recurse into subdirectories - #[arg(long, short = 'R')] - recursive: bool, - }, - - /// Add a new section - Add { - /// Headline title - title: String, - /// Target file (required for directory paths) - #[arg(long, short)] - file: Option, - /// Path to org directory or file - #[arg(default_value = ".")] - path: PathBuf, - /// Headline level (1-6) - #[arg(long, short, default_value = "1")] - level: u8, - /// TODO keyword - #[arg(long)] - todo: Option, - /// Priority (A, B, C) - #[arg(long)] - priority: Option, - /// Tags (comma-separated) - #[arg(long, short)] - tags: Option, - /// Body content - #[arg(long, short)] - body: Option, - /// Insert after section with this ID - #[arg(long)] - after: Option, - /// Insert as child of section with this ID - #[arg(long)] - parent: Option, - /// Dry run - show what would be added - #[arg(long, short = 'n')] - dry_run: bool, - }, - - /// Set or update a property - Property { - /// Section identifier - id: String, - /// Property name - name: String, - /// Property value (omit to delete) - value: Option, - /// Path to org directory or file - #[arg(default_value = ".")] - path: PathBuf, - /// Dry run - #[arg(long, short = 'n')] - dry_run: bool, - /// Recurse into subdirectories - #[arg(long, short = 'R')] - recursive: bool, - }, - - /// Set or add a tag - Tag { - /// Section identifier - id: String, - /// Tag to add/remove - tag: String, - /// Path to org directory or file - #[arg(default_value = ".")] - path: PathBuf, - /// Remove the tag instead of adding - #[arg(long, short)] - remove: bool, - /// Dry run - #[arg(long, short = 'n')] - dry_run: bool, - /// Recurse into subdirectories - #[arg(long, short = 'R')] - recursive: bool, - }, - - /// Validate org file syntax - Check { - /// Path to org directory or file - #[arg(default_value = ".")] - path: PathBuf, - /// Output as JSON - #[arg(long, short)] - json: bool, - /// Recurse into subdirectories - #[arg(long, short = 'R')] - recursive: bool, - }, - - /// Output agenda-relevant items + /// Show an agenda view Agenda { - /// Path to org directory or file - #[arg(default_value = ".")] - path: PathBuf, - /// Number of days to show (default: 7) - #[arg(long, short, default_value = "7")] - days: u32, - /// Output as JSON - #[arg(long, short)] - json: bool, - /// Include done items - #[arg(long)] - include_done: bool, - /// Recurse into subdirectories - #[arg(long, short = 'R')] - recursive: bool, + /// View: today, week, or todos (default: todos) + #[arg(default_value = "todos")] + view: String, }, - /// List org files in directory - Files { - /// Path to org directory - #[arg(default_value = ".")] - path: PathBuf, - /// Output as JSON - #[arg(long, short)] - json: bool, - /// Recurse into subdirectories - #[arg(long, short = 'R')] - recursive: bool, + /// Show a section's fields + Get { + /// Document name + doc: String, + /// Section id (slug or UUID, as shown by `sections`) + id: String, }, + + /// Set a field on a section + Set { + /// Document name + doc: String, + /// Section id + id: String, + /// Field: title, keyword, priority, tags, scheduled, deadline, closed, properties, body + field: String, + /// New value + value: String, + }, + + /// Add a new section to a document + Add { + /// Document name + doc: String, + /// Headline text, e.g. "* TODO Write docs :work:" + headline: String, + /// Add as a child of this section id instead of top-level + #[arg(long, short)] + parent: Option, + }, + + /// Create a new document + Mkdoc { + /// Document name (without .org) + name: String, + }, + + /// Ask the server to rescan org files from disk + Reload, +} + +/// Section fields that may be written via `set`. +const SETTABLE_FIELDS: &[&str] = &[ + "title", "keyword", "priority", "tags", "scheduled", "deadline", "closed", + "properties", "body", +]; + +/// Fields shown by `get`, in display order (read-only "headline" first). +const GET_FIELDS: &[&str] = &[ + "headline", "keyword", "priority", "tags", "scheduled", "deadline", "closed", + "properties", "body", +]; + +/// Entries under a document dir that are not sections. +const DOC_FILES: &[&str] = &["raw", "body", "meta", "new"]; + +/// Resolve the server address: explicit --addr, then $ORK_ADDR, then default. +fn resolve_addr(addr: Option<&str>) -> String { + addr.map(str::to_string) + .or_else(|| std::env::var("ORK_ADDR").ok()) + .unwrap_or_else(|| "tcp://localhost:5640".to_string()) +} + +/// Read stdin to end (used when a write/rdwr DATA argument is omitted). +fn read_stdin() -> io::Result> { + let mut buf = Vec::new(); + io::stdin().read_to_end(&mut buf)?; + Ok(buf) +} + +/// Read a namespace file and return its contents as a trimmed string. +fn read_str(client: &mut Client, path: &str) -> io::Result { + let data = client.read(path)?; + Ok(String::from_utf8_lossy(&data).trim_end().to_string()) +} + +// ── Friendly commands (pure 9P compositions) ─────────────────────────────── + +/// `docs` — list documents from /idx as "name title". +fn cmd_docs(client: &mut Client) -> io::Result<()> { + let idx = read_str(client, "/idx")?; + let mut out = io::stdout().lock(); + for line in idx.lines().filter(|l| !l.is_empty()) { + // idx format: name\tpath\ttitle + let mut cols = line.splitn(3, '\t'); + let name = cols.next().unwrap_or(""); + let _path = cols.next().unwrap_or(""); + let title = cols.next().unwrap_or(""); + writeln!(out, "{:<20} {}", name, title)?; + } + Ok(()) +} + +/// `sections ` — list top-level sections with their headlines. +fn cmd_sections(client: &mut Client, doc: &str) -> io::Result<()> { + let entries = client.list(&format!("/{}", doc))?; + let ids: Vec = entries + .into_iter() + .filter(|e| !DOC_FILES.contains(&e.as_str())) + .collect(); + let mut out = io::stdout().lock(); + if ids.is_empty() { + writeln!(out, "(no sections)")?; + return Ok(()); + } + for id in ids { + let headline = read_str(client, &format!("/{}/{}/headline", doc, id)) + .unwrap_or_default(); + writeln!(out, "{:<28} {}", id, headline)?; + } + Ok(()) +} + +/// `todos [doc]` — read /agenda/todos and optionally filter by document. +fn cmd_todos(client: &mut Client, doc: Option<&str>) -> io::Result<()> { + let todos = read_str(client, "/agenda/todos")?; + let mut out = io::stdout().lock(); + for line in todos.lines().filter(|l| !l.is_empty()) { + // agenda format: id\tdoc\ttitle\tkeyword + let cols: Vec<&str> = line.splitn(4, '\t').collect(); + if cols.len() < 4 { + continue; + } + let (id, ldoc, title, kw) = (cols[0], cols[1], cols[2], cols[3]); + if let Some(want) = doc { + if ldoc != want { + continue; + } + } + writeln!(out, "{:<10} {:<12} {:<28} {}", kw, ldoc, id, title)?; + } + Ok(()) +} + +/// `agenda [view]` — read /agenda/ verbatim. +fn cmd_agenda(client: &mut Client, view: &str) -> io::Result<()> { + match view { + "today" | "week" | "todos" => {} + other => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("unknown agenda view '{}' (use today, week, or todos)", other), + )); + } + } + let data = read_str(client, &format!("/agenda/{}", view))?; + let mut out = io::stdout().lock(); + if data.is_empty() { + writeln!(out, "(empty)")?; + } else { + writeln!(out, "{}", data)?; + } + Ok(()) +} + +/// `get ` — print a section's non-empty fields. +fn cmd_get(client: &mut Client, doc: &str, id: &str) -> io::Result<()> { + // Confirm the section exists by walking its headline first. + let base = format!("/{}/{}", doc, id); + let headline = read_str(client, &format!("{}/headline", base)) + .map_err(|_| io::Error::new(io::ErrorKind::NotFound, + format!("section '{}' not found in '{}'", id, doc)))?; + let mut out = io::stdout().lock(); + writeln!(out, "{}", headline)?; + for field in GET_FIELDS { + if *field == "headline" { + continue; + } + let val = read_str(client, &format!("{}/{}", base, field)).unwrap_or_default(); + if !val.is_empty() { + writeln!(out, " {:<11} {}", format!("{}:", field), val)?; + } + } + Ok(()) +} + +/// `set ` — write one section field. +fn cmd_set(client: &mut Client, doc: &str, id: &str, field: &str, value: &str) -> io::Result<()> { + if !SETTABLE_FIELDS.contains(&field) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("cannot set '{}'; settable fields: {}", field, SETTABLE_FIELDS.join(", ")), + )); + } + client.write(&format!("/{}/{}/{}", doc, id, field), value.as_bytes())?; + Ok(()) +} + +/// `add [--parent id]` — create a section, print its UUID. +fn cmd_add(client: &mut Client, doc: &str, headline: &str, parent: Option<&str>) -> io::Result<()> { + let path = match parent { + Some(pid) => format!("/{}/{}/new", doc, pid), + None => format!("/{}/new", doc), + }; + let resp = client.rdwr(&path, headline.as_bytes())?; + io::stdout().write_all(&resp)?; + Ok(()) +} + +/// `mkdoc ` — create a new document, print its filename. +fn cmd_mkdoc(client: &mut Client, name: &str) -> io::Result<()> { + let resp = client.rdwr("/new", name.as_bytes())?; + io::stdout().write_all(&resp)?; + Ok(()) +} + +/// `reload` — ask the server to rescan. +fn cmd_reload(client: &mut Client) -> io::Result<()> { + let resp = client.rdwr("/ctl", b"reload")?; + io::stdout().write_all(&resp)?; + Ok(()) +} + +fn run(cli: Cli) -> io::Result<()> { + let addr = resolve_addr(cli.addr.as_deref()); + let mut client = Client::connect(&addr) + .map_err(|e| io::Error::new(e.kind(), format!("connect {}: {}", addr, e)))?; + + match cli.command { + Commands::Ls { path } => { + let mut out = io::stdout().lock(); + for name in client.list(&path)? { + writeln!(out, "{}", name)?; + } + } + Commands::Read { path } => { + let data = client.read(&path)?; + io::stdout().write_all(&data)?; + } + Commands::Write { path, data } => { + let bytes = match data { + Some(s) => s.into_bytes(), + None => read_stdin()?, + }; + client.write(&path, &bytes)?; + } + Commands::Rdwr { path, data } => { + let bytes = match data { + Some(s) => s.into_bytes(), + None => read_stdin()?, + }; + let resp = client.rdwr(&path, &bytes)?; + io::stdout().write_all(&resp)?; + } + Commands::Docs => cmd_docs(&mut client)?, + Commands::Sections { doc } => cmd_sections(&mut client, &doc)?, + Commands::Todos { doc } => cmd_todos(&mut client, doc.as_deref())?, + Commands::Agenda { view } => cmd_agenda(&mut client, &view)?, + Commands::Get { doc, id } => cmd_get(&mut client, &doc, &id)?, + Commands::Set { doc, id, field, value } => { + cmd_set(&mut client, &doc, &id, &field, &value)? + } + Commands::Add { doc, headline, parent } => { + cmd_add(&mut client, &doc, &headline, parent.as_deref())? + } + Commands::Mkdoc { name } => cmd_mkdoc(&mut client, &name)?, + Commands::Reload => cmd_reload(&mut client)?, + } + + Ok(()) } fn main() { let cli = Cli::parse(); - - let result = match cli.command { - Commands::Info { path, json, recursive } => { - commands::info(&path, json, recursive) + if let Err(e) = run(cli) { + // A downstream reader closing early (e.g. `| head`) is not an error. + if e.kind() == io::ErrorKind::BrokenPipe { + process::exit(0); } - Commands::Sections { - path, - json, - tag, - todo, - depth, - todos_only, - recursive, - } => commands::sections(&path, json, tag, todo, depth, todos_only, recursive), - Commands::Tags { path, json, recursive } => { - commands::tags(&path, json, recursive) - } - Commands::Query { query, path, json, recursive } => { - commands::query(&path, &query, json, recursive) - } - Commands::Get { id, path, json, body, recursive } => { - commands::get(&path, &id, json, body, recursive) - } - Commands::Export { path, section, pretty, recursive } => { - commands::export(&path, section.as_deref(), pretty, recursive) - } - Commands::Toggle { id, path, dry_run, recursive } => { - commands::toggle(&path, &id, dry_run, recursive) - } - Commands::Add { - title, - file, - path, - level, - todo, - priority, - tags, - body, - after, - parent, - dry_run, - } => commands::add( - &path, - file.as_deref(), - level, - &title, - todo.as_deref(), - priority, - tags.as_deref(), - body.as_deref(), - after.as_deref(), - parent.as_deref(), - dry_run, - ), - Commands::Property { - id, - name, - value, - path, - dry_run, - recursive, - } => commands::property(&path, &id, &name, value.as_deref(), dry_run, recursive), - Commands::Tag { - id, - tag, - path, - remove, - dry_run, - recursive, - } => commands::tag(&path, &id, &tag, remove, dry_run, recursive), - Commands::Check { path, json, recursive } => { - commands::check(&path, json, recursive) - } - Commands::Agenda { - path, - days, - json, - include_done, - recursive, - } => commands::agenda(&path, days, json, include_done, recursive), - Commands::Files { path, json, recursive } => { - commands::files(&path, json, recursive) - } - }; - - if let Err(e) = result { eprintln!("error: {}", e); - std::process::exit(1); + process::exit(1); } }