From 12869fb9f00c2b32bfb73b4d1c301c372a4fa0ef Mon Sep 17 00:00:00 2001 From: Levi Neely <141506390+lneely@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:52:33 +0200 Subject: [PATCH] Add structural ops, query, lint, and real agenda views to ork-server Rework /ctl into a self-describing dispatch table (name, desc, fn), inspired by the ollie 9P server: an empty write or "help" lists the verbs, and unknown verbs report the valid set. New control verbs, each operating on a section subtree: - rm: delete a section and its subtree - move up|down: reorder among siblings - promote/demote: shift the subtree's outline level - refile [parent]: move a subtree across or within documents, re-leveling to the target depth - archive: move a subtree to the companion .org_archive file - lint [doc]: reparse and report per document Add a root /query file (rdwr): write a tag:X+todo:Y+priority:Z match expression, read back matching sections. Implement the previously stubbed agenda today/week views with real date filtering, using a dependency-free civil-date day-number calculation. Subtree edits share span-based helpers (subtree_range, sibling_ranges, shift_headline_levels, insert_at). Covered by unit tests. --- crates/ork-server/src/namespace.rs | 136 ++++++- crates/ork-server/src/state.rs | 576 ++++++++++++++++++++++++++++- 2 files changed, 701 insertions(+), 11 deletions(-) diff --git a/crates/ork-server/src/namespace.rs b/crates/ork-server/src/namespace.rs index 665aa92..efaac60 100644 --- a/crates/ork-server/src/namespace.rs +++ b/crates/ork-server/src/namespace.rs @@ -34,6 +34,15 @@ pub fn build_namespace(state: Arc) -> FsNode { move || Ok(state.keywords().into_bytes()) })), + // query - write a match expression, read matching sections + virtfs::file("query", 0o666, virtfs::rdwr({ + let state = state.clone(); + move |data| { + let expr = String::from_utf8_lossy(data); + state.query(&expr).map(|s| s.into_bytes()) + } + })), + // new - create new org file virtfs::file("new", 0o666, virtfs::rdwr({ let state = state.clone(); @@ -427,14 +436,79 @@ fn build_todos_view(state: &OrkState) -> String { lines.join("\n") + "\n" } +/// Sections whose SCHEDULED or DEADLINE falls today. fn build_today_view(state: &OrkState) -> String { - // TODO: implement with actual date filtering - build_todos_view(state) + build_dated_view(state, 1) } +/// Sections whose SCHEDULED or DEADLINE falls within the next `span` days +/// (starting today). fn build_week_view(state: &OrkState) -> String { - // TODO: implement with actual date filtering - build_todos_view(state) + build_dated_view(state, 7) +} + +/// List sections with a planning date in `[today, today + days)`, formatted as +/// `date\tkind\tid\tdoc\ttitle`. +fn build_dated_view(state: &OrkState, days: i64) -> String { + let today = today_daynum(); + let mut lines = Vec::new(); + + for doc_name in state.doc_names() { + let Some(doc_state) = state.get_doc(&doc_name) else { continue }; + for section in doc_state.doc.all_sections() { + let Some(planning) = section.planning.as_ref() else { continue }; + for (kind, ts) in [ + ("SCHEDULED", planning.scheduled.as_ref()), + ("DEADLINE", planning.deadline.as_ref()), + ] { + let Some(ts) = ts else { continue }; + let Some(date) = timestamp_date(ts) else { continue }; + let dn = daynum(date.year as i64, date.month as i64, date.day as i64); + if dn >= today && dn < today + days { + lines.push(format!( + "{:04}-{:02}-{:02}\t{}\t{}\t{}\t{}", + date.year, date.month, date.day, kind, + state.section_id(section), doc_name, + section.headline.title_text(), + )); + } + } + } + } + + lines.sort(); + lines.join("\n") + if lines.is_empty() { "" } else { "\n" } +} + +/// Extract the (start) date of a timestamp, if it has one. +fn timestamp_date(ts: &org_ast::Timestamp) -> Option<&org_ast::Date> { + match &ts.value { + org_ast::TimestampValue::Point(p) => Some(&p.date), + org_ast::TimestampValue::Range { start, .. } => Some(&start.date), + org_ast::TimestampValue::Diary(_) => None, + } +} + +/// Days since an arbitrary epoch for a proleptic-Gregorian civil date. +/// From Howard Hinnant's `days_from_civil` algorithm. +fn daynum(y: i64, m: i64, d: i64) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = (y - era * 400) as i64; // [0, 399] + let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; // [0, 365] + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096] + era * 146097 + doe - 719468 +} + +/// Today's civil date as a day number, from the system clock (UTC). +fn today_daynum() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + // Unix epoch (1970-01-01) has day number 0 in this scheme. + secs.div_euclid(86_400) } #[cfg(test)] @@ -443,6 +517,60 @@ mod tests { use std::fs; use tempfile::tempdir; + #[test] + fn test_daynum_civil() { + // 1970-01-01 is day 0; the next day is 1; leap years handled. + assert_eq!(daynum(1970, 1, 1), 0); + assert_eq!(daynum(1970, 1, 2), 1); + assert_eq!(daynum(2000, 3, 1) - daynum(2000, 2, 28), 2); // 2000 is a leap year + assert_eq!(daynum(2001, 3, 1) - daynum(2001, 2, 28), 1); + } + + #[test] + fn test_dated_view_windows() { + let dir = tempdir().unwrap(); + // Build dates relative to the server's notion of "today". + let today = today_daynum(); + let ymd = |dn: i64| civil_from_daynum(dn); + let (ty, tm, td) = ymd(today); + let (fy, fm, fd) = ymd(today + 3); // inside week, outside today + let (oy, om, od) = ymd(today + 30); // outside both + let content = format!( + "#+TITLE: W\n\ + * TODO Today\nSCHEDULED: <{:04}-{:02}-{:02}>\n\ + * TODO Soon\nDEADLINE: <{:04}-{:02}-{:02}>\n\ + * TODO Later\nSCHEDULED: <{:04}-{:02}-{:02}>\n", + ty, tm, td, fy, fm, fd, oy, om, od, + ); + fs::write(dir.path().join("w.org"), content).unwrap(); + let state = OrkState::new(dir.path()).unwrap(); + + let today_view = build_today_view(&state); + assert!(today_view.contains("Today")); + assert!(!today_view.contains("Soon")); + assert!(!today_view.contains("Later")); + + let week_view = build_week_view(&state); + assert!(week_view.contains("Today")); + assert!(week_view.contains("Soon")); + assert!(!week_view.contains("Later")); + } + + /// Inverse of `daynum` for building test dates (Hinnant's civil_from_days). + fn civil_from_daynum(dn: i64) -> (u16, u8, u8) { + let z = dn + 719468; + let era = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + (y as u16, m as u8, d as u8) + } + #[test] fn test_namespace_structure() { let dir = tempdir().unwrap(); diff --git a/crates/ork-server/src/state.rs b/crates/ork-server/src/state.rs index 6b3f75d..f9e6953 100644 --- a/crates/ork-server/src/state.rs +++ b/crates/ork-server/src/state.rs @@ -194,16 +194,107 @@ impl OrkState { "TODO NEXT WAITING | DONE CANCELLED\n".to_string() } - /// Handle control commands. + /// Handle control commands via a self-describing dispatch table. + /// + /// An empty write or `help` returns the command listing; each verb is a + /// `{name, desc, fn}` entry dispatched by its first word. Inspired by the + /// ctl surface in the ollie 9P server. pub fn handle_ctl(&self, cmd: &str) -> Result { + type CtlFn = fn(&OrkState, &[&str]) -> Result; + const CMDS: &[(&str, &str, CtlFn)] = &[ + ("reload", "rescan org files from disk", OrkState::ctl_reload), + ("rm", "rm — delete a section and its subtree", OrkState::ctl_rm), + ("move", "move up|down — reorder among siblings", OrkState::ctl_move), + ("promote", "promote — decrease subtree level", OrkState::ctl_promote), + ("demote", "demote — increase subtree level", OrkState::ctl_demote), + ("refile", "refile [parent-id] — move subtree", OrkState::ctl_refile), + ("archive", "archive — move subtree to .org_archive", OrkState::ctl_archive), + ("lint", "lint [doc] — validate documents", OrkState::ctl_lint), + ]; + let cmd = cmd.trim(); - match cmd { - "reload" | "rescan" => { - self.scan()?; - Ok("ok\n".to_string()) + if cmd.is_empty() || cmd == "help" { + let mut out = String::new(); + for (name, desc, _) in CMDS { + out.push_str(&format!("{:<10} {}\n", name, desc)); } - _ => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("unknown command: {}", cmd))), + return Ok(out); } + + let parts: Vec<&str> = cmd.split_whitespace().collect(); + let verb = parts[0]; + let args = &parts[1..]; + + // Accept "rescan" as an alias for "reload". + let verb = if verb == "rescan" { "reload" } else { verb }; + + for (name, _, f) in CMDS { + if *name == verb { + return f(self, args); + } + } + let names: Vec<&str> = CMDS.iter().map(|(n, _, _)| *n).collect(); + Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("unknown ctl command {:?}; valid: {}", verb, names.join(", ")), + )) + } + + fn ctl_reload(&self, _args: &[&str]) -> Result { + self.scan()?; + Ok("ok\n".to_string()) + } + + fn ctl_rm(&self, args: &[&str]) -> Result { + let (doc, id) = two_args(args, "rm ")?; + self.delete_section(doc, id)?; + Ok("ok\n".to_string()) + } + + fn ctl_move(&self, args: &[&str]) -> Result { + if args.len() != 3 { + return Err(bad_args("move up|down")); + } + let dir = match args[2] { + "up" => MoveDir::Up, + "down" => MoveDir::Down, + other => return Err(io::Error::new(io::ErrorKind::InvalidInput, + format!("direction must be up or down, got {:?}", other))), + }; + self.move_section(args[0], args[1], dir)?; + Ok("ok\n".to_string()) + } + + fn ctl_promote(&self, args: &[&str]) -> Result { + let (doc, id) = two_args(args, "promote ")?; + self.relevel_section(doc, id, -1)?; + Ok("ok\n".to_string()) + } + + fn ctl_demote(&self, args: &[&str]) -> Result { + let (doc, id) = two_args(args, "demote ")?; + self.relevel_section(doc, id, 1)?; + Ok("ok\n".to_string()) + } + + fn ctl_refile(&self, args: &[&str]) -> Result { + if args.len() < 3 || args.len() > 4 { + return Err(bad_args("refile [parent-id]")); + } + let parent = args.get(3).copied(); + self.refile_section(args[0], args[1], args[2], parent)?; + Ok("ok\n".to_string()) + } + + fn ctl_archive(&self, args: &[&str]) -> Result { + let (doc, id) = two_args(args, "archive ")?; + let dest = self.archive_section(doc, id)?; + Ok(format!("{}\n", dest)) + } + + fn ctl_lint(&self, args: &[&str]) -> Result { + let target = args.first().copied(); + Ok(self.lint(target)) } } @@ -239,7 +330,7 @@ impl OrkState { } /// Generate section ID: CUSTOM_ID > ID > slugified title. - fn section_id(&self, section: &org_ast::Section) -> String { + pub fn section_id(&self, section: &org_ast::Section) -> String { if let Some(id) = section.custom_id() { return id.to_string(); } @@ -859,6 +950,374 @@ impl OrkState { } } +// ═══════════════════════════════════════════════════════════════════ +// Structural Operations (delete, move, relevel, refile, archive, lint) +// ═══════════════════════════════════════════════════════════════════ + +/// Direction for `move_section`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MoveDir { + Up, + Down, +} + +impl OrkState { + /// Byte range [start, end) of a section's full subtree (headline through + /// the end of its content and all descendants). + fn subtree_range(&self, doc: &Document, id: &str) -> Option<(usize, usize)> { + let s = self.find_section(doc, id)?; + Some((s.headline.span.start.offset, s.span.end.offset)) + } + + /// Sibling subtree ranges (in document order) for the section with `id`, + /// plus the index of `id` within them. Siblings are the children of the + /// same parent, or the top-level sections when `id` is top-level. + fn sibling_ranges(&self, doc: &Document, id: &str) -> Option<(Vec<(usize, usize)>, usize)> { + // Top-level? + let top: Vec<&org_ast::Section> = doc.sections.iter().collect(); + if let Some(i) = top.iter().position(|s| self.section_id(s) == id) { + let ranges = top.iter() + .map(|s| (s.headline.span.start.offset, s.span.end.offset)) + .collect(); + return Some((ranges, i)); + } + // Otherwise find the parent whose children include id. + for parent in doc.all_sections() { + if let Some(i) = parent.children.iter().position(|c| self.section_id(c) == id) { + let ranges = parent.children.iter() + .map(|c| (c.headline.span.start.offset, c.span.end.offset)) + .collect(); + return Some((ranges, i)); + } + } + None + } + + /// Delete a section and its entire subtree. + pub fn delete_section(&self, doc_name: &str, id: &str) -> Result<()> { + let doc_state = self.get_doc(doc_name) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "document not found"))?; + let (start, end) = self.subtree_range(&doc_state.doc, id) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "section not found"))?; + let mut content = String::with_capacity(doc_state.content.len()); + content.push_str(&doc_state.content[..start]); + content.push_str(&doc_state.content[end..]); + self.update_doc(doc_name, &doc_state.path, content) + } + + /// Reorder a section among its siblings. + pub fn move_section(&self, doc_name: &str, id: &str, dir: MoveDir) -> Result<()> { + let doc_state = self.get_doc(doc_name) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "document not found"))?; + let (ranges, idx) = self.sibling_ranges(&doc_state.doc, id) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "section not found"))?; + + let other = match dir { + MoveDir::Up if idx > 0 => idx - 1, + MoveDir::Down if idx + 1 < ranges.len() => idx + 1, + // Already at the boundary: nothing to do. + _ => return Ok(()), + }; + let (a, b) = if idx < other { (idx, other) } else { (other, idx) }; + let (a_start, a_end) = ranges[a]; + let (b_start, b_end) = ranges[b]; + + let content = &doc_state.content; + let mut new = String::with_capacity(content.len()); + new.push_str(&content[..a_start]); + new.push_str(&content[b_start..b_end]); // second block first + new.push_str(&content[a_end..b_start]); // any gap between them + new.push_str(&content[a_start..a_end]); // first block second + new.push_str(&content[b_end..]); + self.update_doc(doc_name, &doc_state.path, new) + } + + /// Shift a section's subtree level by `delta` (negative promotes). + pub fn relevel_section(&self, doc_name: &str, id: &str, delta: i32) -> Result<()> { + let doc_state = self.get_doc(doc_name) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "document not found"))?; + let section = self.find_section(&doc_state.doc, id) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "section not found"))?; + if delta < 0 && section.headline.level <= 1 { + return Err(io::Error::new(io::ErrorKind::InvalidInput, + "cannot promote a top-level section")); + } + let (start, end) = (section.headline.span.start.offset, section.span.end.offset); + let subtree = shift_headline_levels(&doc_state.content[start..end], delta); + let mut new = String::with_capacity(doc_state.content.len()); + new.push_str(&doc_state.content[..start]); + new.push_str(&subtree); + new.push_str(&doc_state.content[end..]); + self.update_doc(doc_name, &doc_state.path, new) + } + + /// Move a section's subtree to another document (or elsewhere in the same + /// document), re-leveling it to the target depth. `to_parent` places it as + /// a child of that section; otherwise it becomes a top-level section. + pub fn refile_section( + &self, + doc_name: &str, + id: &str, + to_doc: &str, + to_parent: Option<&str>, + ) -> Result<()> { + let src = self.get_doc(doc_name) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "source document not found"))?; + let dst = self.get_doc(to_doc) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "target document not found"))?; + + let section = self.find_section(&src.doc, id) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "section not found"))?; + let (start, end) = (section.headline.span.start.offset, section.span.end.offset); + let src_level = section.headline.level as i32; + + // Target level: one below the parent, or top-level. + let target_level = match to_parent { + Some(pid) => { + let parent = self.find_section(&dst.doc, pid) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "parent section not found"))?; + parent.headline.level as i32 + 1 + } + None => 1, + }; + + // Extract and re-level the subtree text. + let mut subtree = shift_headline_levels(&src.content[start..end], target_level - src_level); + if !subtree.ends_with('\n') { + subtree.push('\n'); + } + + // Same-document refile: compute both edits against one content string. + if doc_name == to_doc { + // Remove the subtree, then recompute the insertion point on the + // post-removal content by reparsing. + let mut content = String::with_capacity(src.content.len()); + content.push_str(&src.content[..start]); + content.push_str(&src.content[end..]); + self.update_doc(doc_name, &src.path, content)?; + + let doc_state = self.get_doc(to_doc).unwrap(); + let insert_pos = match to_parent { + Some(pid) => { + let parent = self.find_section(&doc_state.doc, pid) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "parent section not found"))?; + self.find_section_end(&doc_state.content, parent) + } + None => doc_state.content.len(), + }; + let new = insert_at(&doc_state.content, insert_pos, &subtree); + return self.update_doc(to_doc, &doc_state.path, new); + } + + // Cross-document refile: insert into target, then remove from source. + let insert_pos = match to_parent { + Some(pid) => { + let parent = self.find_section(&dst.doc, pid).unwrap(); + self.find_section_end(&dst.content, parent) + } + None => dst.content.len(), + }; + let dst_new = insert_at(&dst.content, insert_pos, &subtree); + self.update_doc(to_doc, &dst.path, dst_new)?; + + let mut src_new = String::with_capacity(src.content.len()); + src_new.push_str(&src.content[..start]); + src_new.push_str(&src.content[end..]); + self.update_doc(doc_name, &src.path, src_new) + } + + /// Move a section's subtree to `.org_archive` and remove it from the + /// source. Returns the archive filename. + pub fn archive_section(&self, doc_name: &str, id: &str) -> Result { + let src = self.get_doc(doc_name) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "document not found"))?; + let (start, end) = self.subtree_range(&src.doc, id) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "section not found"))?; + + let mut subtree = src.content[start..end].to_string(); + if !subtree.ends_with('\n') { + subtree.push('\n'); + } + + // Append to the companion archive file (created if missing). + let archive_path = archive_path_for(&src.path); + let mut archive = if archive_path.exists() { + fs::read_to_string(&archive_path)? + } else { + String::new() + }; + if !archive.is_empty() && !archive.ends_with('\n') { + archive.push('\n'); + } + archive.push_str(&subtree); + fs::write(&archive_path, &archive)?; + + // Remove the subtree from the source document. + let mut content = String::with_capacity(src.content.len()); + content.push_str(&src.content[..start]); + content.push_str(&src.content[end..]); + self.update_doc(doc_name, &src.path, content)?; + + Ok(archive_path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string()) + } + + /// Validate documents by reparsing. Reports one line per document. + /// With no target, lints every document. + pub fn lint(&self, target: Option<&str>) -> String { + let names: Vec = match target { + Some(t) => vec![t.to_string()], + None => { + let mut n = self.doc_names(); + n.sort(); + n + } + }; + let mut out = String::new(); + for name in names { + match self.get_doc(&name) { + None => out.push_str(&format!("{}\tnot found\n", name)), + Some(d) => match self.parser.write().parse(&d.content) { + Ok(_) => out.push_str(&format!("{}\tok\n", name)), + Err(e) => out.push_str(&format!("{}\terror: {}\n", name, e)), + }, + } + } + out + } + + /// Run a simple query and return matching sections as + /// `id\tdoc\ttitle\tkeyword` lines. The expression is a `+`-separated + /// conjunction of `tag:X`, `todo:X`, `todo` (any), or `priority:X` terms. + pub fn query(&self, expr: &str) -> Result { + let terms: Vec = expr + .split('+') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(QueryTerm::parse) + .collect::>()?; + + let mut lines = Vec::new(); + let mut names = self.doc_names(); + names.sort(); + for doc_name in names { + let Some(d) = self.get_doc(&doc_name) else { continue }; + for section in d.doc.all_sections() { + if terms.iter().all(|t| t.matches(section)) { + let id = self.section_id(section); + let kw = section.headline.keyword.as_ref() + .map(|k| k.keyword.as_str()).unwrap_or(""); + lines.push(format!("{}\t{}\t{}\t{}", + id, doc_name, section.headline.title_text(), kw)); + } + } + } + lines.sort(); + Ok(lines.join("\n") + if lines.is_empty() { "" } else { "\n" }) + } +} + +/// One parsed query term. +enum QueryTerm { + Tag(String), + Todo(Option), + Priority(char), +} + +impl QueryTerm { + fn parse(s: &str) -> Result { + if let Some(v) = s.strip_prefix("tag:") { + Ok(QueryTerm::Tag(v.to_string())) + } else if s == "todo" { + Ok(QueryTerm::Todo(None)) + } else if let Some(v) = s.strip_prefix("todo:") { + Ok(QueryTerm::Todo(Some(v.to_string()))) + } else if let Some(v) = s.strip_prefix("priority:") { + let c = v.chars().next() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "empty priority"))?; + Ok(QueryTerm::Priority(c.to_ascii_uppercase())) + } else { + Err(io::Error::new(io::ErrorKind::InvalidInput, + format!("bad query term {:?}; use tag:X, todo[:X], or priority:X", s))) + } + } + + fn matches(&self, section: &org_ast::Section) -> bool { + match self { + QueryTerm::Tag(t) => section.has_tag(t), + QueryTerm::Todo(None) => section.is_todo() || section.is_done(), + QueryTerm::Todo(Some(kw)) => section.headline.keyword.as_ref() + .map_or(false, |k| k.keyword == *kw), + QueryTerm::Priority(p) => section.headline.priority.map_or(false, |pr| match pr { + org_ast::Priority::A => *p == 'A', + org_ast::Priority::B => *p == 'B', + org_ast::Priority::C => *p == 'C', + org_ast::Priority::Numeric(_) => false, + }), + } + } +} + +/// Require exactly two positional args (doc, id). +fn two_args<'a>(args: &[&'a str], usage: &str) -> Result<(&'a str, &'a str)> { + if args.len() != 2 { + return Err(bad_args(usage)); + } + Ok((args[0], args[1])) +} + +fn bad_args(usage: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, format!("usage: {}", usage)) +} + +/// Insert `text` into `content` at byte offset `pos`, ensuring the insertion +/// begins on its own line. +fn insert_at(content: &str, pos: usize, text: &str) -> String { + let mut out = String::with_capacity(content.len() + text.len() + 1); + out.push_str(&content[..pos]); + if pos > 0 && !content[..pos].ends_with('\n') { + out.push('\n'); + } + out.push_str(text); + out.push_str(&content[pos..]); + out +} + +/// The companion archive path for a document: `_archive`. +fn archive_path_for(path: &Path) -> PathBuf { + let mut s = path.as_os_str().to_os_string(); + s.push("_archive"); + PathBuf::from(s) +} + +/// Shift the level of every headline line in `text` by `delta`, clamping to a +/// minimum of level 1. A headline line begins at column 0 with one or more +/// `*` followed by a space (or end of line). +fn shift_headline_levels(text: &str, delta: i32) -> String { + let mut out = String::with_capacity(text.len()); + for line in text.split_inclusive('\n') { + let trimmed = line.trim_end_matches('\n'); + let stars = trimmed.chars().take_while(|c| *c == '*').count(); + // A headline is `*`+ followed by a space, or a bare run of `*`. + let is_headline = stars > 0 + && (trimmed[stars..].starts_with(' ') || trimmed.len() == stars); + if is_headline { + let new_level = ((stars as i32 + delta).max(1)) as usize; + let rest = &trimmed[stars..]; + out.push_str(&"*".repeat(new_level)); + out.push_str(rest); + if line.ends_with('\n') { + out.push('\n'); + } + } else { + out.push_str(line); + } + } + out +} + /// Replace the leading run of `*` in a headline with exactly `level` stars, /// preserving the rest of the line. fn restar(headline: &str, level: usize) -> String { @@ -934,4 +1393,107 @@ mod tests { // The child is walkable by its generated UUID. assert!(state.section_exists("w", &child_uuid)); } + + #[test] + fn test_shift_headline_levels() { + let src = "* A\nbody\n** B\n*not a headline\n"; + assert_eq!(shift_headline_levels(src, 1), "** A\nbody\n*** B\n*not a headline\n"); + assert_eq!(shift_headline_levels("*** A\n", -1), "** A\n"); + // Clamp at level 1. + assert_eq!(shift_headline_levels("* A\n", -1), "* A\n"); + } + + fn state_with(content: &str) -> (tempfile::TempDir, OrkState) { + use std::fs; + use tempfile::tempdir; + let dir = tempdir().unwrap(); + fs::write(dir.path().join("w.org"), content).unwrap(); + let state = OrkState::new(dir.path()).unwrap(); + (dir, state) + } + + #[test] + fn test_delete_section() { + let (_d, state) = state_with("#+TITLE: W\n* One\n* Two\n* Three\n"); + state.delete_section("w", "two").unwrap(); + let raw = state.doc_raw("w").unwrap(); + assert!(!raw.contains("* Two")); + assert!(raw.contains("* One") && raw.contains("* Three")); + } + + #[test] + fn test_move_section() { + let (_d, state) = state_with("#+TITLE: W\n* One\n* Two\n* Three\n"); + state.move_section("w", "one", MoveDir::Down).unwrap(); + let raw = state.doc_raw("w").unwrap(); + let one = raw.find("* One").unwrap(); + let two = raw.find("* Two").unwrap(); + assert!(two < one, "Two should now precede One"); + // Boundary is a no-op. + state.move_section("w", "three", MoveDir::Down).unwrap(); + } + + #[test] + fn test_relevel_section() { + let (_d, state) = state_with("#+TITLE: W\n* Parent\n** Child\n* Other\n"); + state.relevel_section("w", "parent", 1).unwrap(); + let raw = state.doc_raw("w").unwrap(); + assert!(raw.contains("** Parent")); + assert!(raw.contains("*** Child"), "child shifts with parent"); + assert!(raw.contains("* Other"), "sibling untouched"); + // Cannot promote a top-level section. + assert!(state.relevel_section("w", "other", -1).is_err()); + } + + #[test] + fn test_refile_cross_document() { + use std::fs; + use tempfile::tempdir; + let dir = tempdir().unwrap(); + fs::write(dir.path().join("a.org"), "#+TITLE: A\n* Move Me\nbody\n").unwrap(); + fs::write(dir.path().join("b.org"), "#+TITLE: B\n* Target\n").unwrap(); + let state = OrkState::new(dir.path()).unwrap(); + + state.refile_section("a", "move-me", "b", Some("target")).unwrap(); + + let a = state.doc_raw("a").unwrap(); + let b = state.doc_raw("b").unwrap(); + assert!(!a.contains("Move Me"), "removed from source"); + assert!(b.contains("** Move Me"), "demoted under target in b"); + assert!(b.contains("body"), "body carried over"); + } + + #[test] + fn test_archive_section() { + use std::fs; + let (dir, state) = state_with("#+TITLE: W\n* Keep\n* Archive Me\nnote\n"); + let dest = state.archive_section("w", "archive-me").unwrap(); + assert_eq!(dest, "w.org_archive"); + let src = state.doc_raw("w").unwrap(); + assert!(!src.contains("Archive Me")); + let archived = fs::read_to_string(dir.path().join("w.org_archive")).unwrap(); + assert!(archived.contains("* Archive Me")); + assert!(archived.contains("note")); + } + + #[test] + fn test_query() { + let (_d, state) = state_with( + "#+TITLE: W\n* TODO [#A] Alpha :work:\n* DONE Beta :work:\n* TODO Gamma\n", + ); + let r = state.query("tag:work+todo:TODO").unwrap(); + assert!(r.contains("Alpha")); + assert!(!r.contains("Beta"), "Beta is DONE"); + assert!(!r.contains("Gamma"), "Gamma lacks the work tag"); + + let r2 = state.query("priority:A").unwrap(); + assert!(r2.contains("Alpha") && !r2.contains("Gamma")); + } + + #[test] + fn test_lint() { + let (_d, state) = state_with("#+TITLE: W\n* One\n"); + let report = state.lint(Some("w")); + assert_eq!(report, "w\tok\n"); + } }