#[derive(Debug, PartialEq, Eq)]
pub(super) enum Command {
Fact { prompt: Option<String>, path: Option<String> },
Note { prompt: Option<String>, path: Option<String> },
Goto(String),
Diff,
Verify,
FactCheck,
Sources,
Import(Option<String>),
Forget(String),
Web { ingest: Option<bool>, query: String },
Calc(String),
Wikidata(String),
OpenAlex(String),
Arxiv(String),
Triangulate(String),
WhatsWrong(Option<String>),
Promote { note: Option<String>, path: Option<String> },
Chain(Vec<String>),
Rag(Option<String>),
Clear,
Save(Option<String>),
Unknown(String),
}
fn split_prompt_and_path(rest: &str) -> (Option<String>, Option<String>) {
let prompt = first_quoted(rest);
let path = arrow_tail(rest);
(prompt, path)
}
fn first_quoted(s: &str) -> Option<String> {
let start = s.find('"')?;
let after = &s[start + 1..];
let end = after.find('"')?;
let inner = after[..end].trim();
if inner.is_empty() { None } else { Some(inner.to_string()) }
}
fn arrow_tail(s: &str) -> Option<String> {
let idx = s.find('→').map(|i| (i, '→'.len_utf8())).or_else(|| s.find("->").map(|i| (i, 2)));
let (i, w) = idx?;
let tail = s[i + w..].trim();
if tail.is_empty() { None } else { Some(tail.to_string()) }
}
pub(super) fn parse(input: &str) -> Option<Command> {
let input = input.trim();
let body = input.strip_prefix('/')?;
let (name, rest) = match body.split_once(char::is_whitespace) {
Some((n, r)) => (n, r.trim()),
None => (body, ""),
};
let cmd = match name.to_ascii_lowercase().as_str() {
"fact" => {
let (prompt, path) = split_prompt_and_path(rest);
let prompt = prompt.or_else(|| {
let head = arrow_head(rest);
if head.is_empty() { None } else { Some(head) }
});
Command::Fact { prompt, path }
}
"note" => {
let (prompt, path) = split_prompt_and_path(rest);
let prompt = prompt.or_else(|| {
let head = arrow_head(rest);
if head.is_empty() { None } else { Some(head) }
});
Command::Note { prompt, path }
}
"goto" => Command::Goto(rest.to_string()),
"diff" => Command::Diff,
"verify" => Command::Verify,
"factcheck" => Command::FactCheck,
"sources" => Command::Sources,
"import" => Command::Import(if rest.is_empty() { None } else { Some(rest.to_string()) }),
"forget" => Command::Forget(rest.to_string()),
"web" => {
let (ingest, q) = if let Some(r) = rest.strip_prefix("--ingest") {
(Some(true), r.trim())
} else if let Some(r) = rest.strip_prefix("--chat") {
(Some(false), r.trim())
} else {
(None, rest)
};
Command::Web { ingest, query: q.to_string() }
}
"calc" => Command::Calc(rest.to_string()),
"wikidata" => Command::Wikidata(rest.to_string()),
"openalex" => Command::OpenAlex(rest.to_string()),
"arxiv" => Command::Arxiv(rest.to_string()),
"triangulate" | "tri" => Command::Triangulate(rest.to_string()),
"whatswrong" => Command::WhatsWrong(if rest.is_empty() { None } else { Some(rest.to_string()) }),
"promote" => {
let note = arrow_head(rest);
let path = arrow_tail(rest);
Command::Promote {
note: if note.is_empty() { None } else { Some(note) },
path,
}
}
"chain" => {
let steps: Vec<String> = split_steps(rest);
Command::Chain(steps)
}
"rag" => Command::Rag(if rest.is_empty() { None } else { Some(rest.to_string()) }),
"clear" => Command::Clear,
"save" => Command::Save(if rest.is_empty() { None } else { Some(rest.to_string()) }),
other => Command::Unknown(other.to_string()),
};
Some(cmd)
}
fn arrow_head(s: &str) -> String {
let cut = s
.find('→')
.or_else(|| s.find("->"))
.unwrap_or(s.len());
s[..cut].trim().to_string()
}
fn split_steps(s: &str) -> Vec<String> {
s.split('→')
.flat_map(|seg| seg.split("->"))
.map(|seg| seg.trim().to_string())
.filter(|seg| !seg.is_empty())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn non_command_is_none() {
assert!(parse("hello world").is_none());
assert!(parse(" not a command").is_none());
}
#[test]
fn fact_with_prompt_and_path() {
let c = parse("/fact \"extract the capacity figure\" → Facts/Rome/Engineering").unwrap();
assert_eq!(
c,
Command::Fact {
prompt: Some("extract the capacity figure".into()),
path: Some("Facts/Rome/Engineering".into()),
}
);
}
#[test]
fn fact_bare_prompt_no_path() {
let c = parse("/fact extract the figure").unwrap();
assert_eq!(c, Command::Fact { prompt: Some("extract the figure".into()), path: None });
}
#[test]
fn fact_empty_argument() {
assert_eq!(parse("/fact").unwrap(), Command::Fact { prompt: None, path: None });
}
#[test]
fn goto_and_simple_commands() {
assert_eq!(parse("/goto facts/rome/eng").unwrap(), Command::Goto("facts/rome/eng".into()));
assert_eq!(parse("/diff").unwrap(), Command::Diff);
assert_eq!(parse("/verify").unwrap(), Command::Verify);
assert_eq!(parse("/factcheck").unwrap(), Command::FactCheck);
assert_eq!(parse("/sources").unwrap(), Command::Sources);
assert_eq!(parse("/import /docs/rome.md").unwrap(), Command::Import(Some("/docs/rome.md".into())));
assert_eq!(parse("/import").unwrap(), Command::Import(None));
assert_eq!(parse("/forget rome").unwrap(), Command::Forget("rome".into()));
assert_eq!(
parse("/web roman aqueduct capacity").unwrap(),
Command::Web { ingest: None, query: "roman aqueduct capacity".into() }
);
assert_eq!(
parse("/web --ingest roman history").unwrap(),
Command::Web { ingest: Some(true), query: "roman history".into() }
);
assert_eq!(
parse("/web --chat q").unwrap(),
Command::Web { ingest: Some(false), query: "q".into() }
);
assert_eq!(parse("/calc 100 mi2km").unwrap(), Command::Calc("100 mi2km".into()));
assert_eq!(parse("/wikidata Rome").unwrap(), Command::Wikidata("Rome".into()));
assert_eq!(parse("/openalex aqueduct").unwrap(), Command::OpenAlex("aqueduct".into()));
assert_eq!(parse("/arxiv attention").unwrap(), Command::Arxiv("attention".into()));
assert_eq!(parse("/triangulate the sky is blue").unwrap(), Command::Triangulate("the sky is blue".into()));
assert_eq!(parse("/tri x").unwrap(), Command::Triangulate("x".into()));
assert_eq!(parse("/whatswrong").unwrap(), Command::WhatsWrong(None));
assert_eq!(
parse("/whatswrong facts/rome/fall").unwrap(),
Command::WhatsWrong(Some("facts/rome/fall".into()))
);
assert_eq!(
parse("/promote notes/rome/idea → Facts/Rome").unwrap(),
Command::Promote { note: Some("notes/rome/idea".into()), path: Some("Facts/Rome".into()) }
);
assert_eq!(parse("/promote").unwrap(), Command::Promote { note: None, path: None });
assert_eq!(parse("/clear").unwrap(), Command::Clear);
assert_eq!(parse("/rag facts").unwrap(), Command::Rag(Some("facts".into())));
assert_eq!(parse("/save rome").unwrap(), Command::Save(Some("rome".into())));
}
#[test]
fn chain_splits_on_arrows() {
let c = parse("/chain a? → b? -> c?").unwrap();
assert_eq!(c, Command::Chain(vec!["a?".into(), "b?".into(), "c?".into()]));
let c2 = parse("/chain a → → b").unwrap();
assert_eq!(c2, Command::Chain(vec!["a".into(), "b".into()]));
}
#[test]
fn unknown_command() {
assert_eq!(parse("/frobnicate x").unwrap(), Command::Unknown("frobnicate".into()));
}
}