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 <to-doc> [parent]: move a subtree across or within documents,
  re-leveling to the target depth
- archive: move a subtree to the companion <doc>.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.
This commit is contained in:
Levi Neely 2026-09-30 21:52:33 +02:00
parent 73c4eb6cff
commit 12869fb9f0
2 changed files with 701 additions and 11 deletions

View File

@ -34,6 +34,15 @@ pub fn build_namespace(state: Arc<OrkState>) -> 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();

View File

@ -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<String> {
type CtlFn = fn(&OrkState, &[&str]) -> Result<String>;
const CMDS: &[(&str, &str, CtlFn)] = &[
("reload", "rescan org files from disk", OrkState::ctl_reload),
("rm", "rm <doc> <id> — delete a section and its subtree", OrkState::ctl_rm),
("move", "move <doc> <id> up|down — reorder among siblings", OrkState::ctl_move),
("promote", "promote <doc> <id> — decrease subtree level", OrkState::ctl_promote),
("demote", "demote <doc> <id> — increase subtree level", OrkState::ctl_demote),
("refile", "refile <doc> <id> <to-doc> [parent-id] — move subtree", OrkState::ctl_refile),
("archive", "archive <doc> <id> — move subtree to <doc>.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<String> {
self.scan()?;
Ok("ok\n".to_string())
}
fn ctl_rm(&self, args: &[&str]) -> Result<String> {
let (doc, id) = two_args(args, "rm <doc> <id>")?;
self.delete_section(doc, id)?;
Ok("ok\n".to_string())
}
fn ctl_move(&self, args: &[&str]) -> Result<String> {
if args.len() != 3 {
return Err(bad_args("move <doc> <id> 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<String> {
let (doc, id) = two_args(args, "promote <doc> <id>")?;
self.relevel_section(doc, id, -1)?;
Ok("ok\n".to_string())
}
fn ctl_demote(&self, args: &[&str]) -> Result<String> {
let (doc, id) = two_args(args, "demote <doc> <id>")?;
self.relevel_section(doc, id, 1)?;
Ok("ok\n".to_string())
}
fn ctl_refile(&self, args: &[&str]) -> Result<String> {
if args.len() < 3 || args.len() > 4 {
return Err(bad_args("refile <doc> <id> <to-doc> [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<String> {
let (doc, id) = two_args(args, "archive <doc> <id>")?;
let dest = self.archive_section(doc, id)?;
Ok(format!("{}\n", dest))
}
fn ctl_lint(&self, args: &[&str]) -> Result<String> {
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 `<doc>.org_archive` and remove it from the
/// source. Returns the archive filename.
pub fn archive_section(&self, doc_name: &str, id: &str) -> Result<String> {
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<String> = 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<String> {
let terms: Vec<QueryTerm> = expr
.split('+')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(QueryTerm::parse)
.collect::<Result<_>>()?;
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<String>),
Priority(char),
}
impl QueryTerm {
fn parse(s: &str) -> Result<Self> {
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: `<path>_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");
}
}