use crate::args::Args;
use crate::failure::Failure;
pub struct Push {
pub title: String,
pub why: String,
pub kind: Option<String>,
pub refs: Vec<String>,
pub governs: Vec<String>,
pub blocks: bool,
pub arms: Vec<String>,
pub arm_dir: Option<String>,
pub against: Vec<String>,
pub via_mcp: bool,
pub root: bool,
pub parent: Option<String>,
}
impl Push {
pub fn from_args(a: &Args) -> Result<Push, Failure> {
let title = a
.positional(0)
.ok_or_else(|| Failure::usage("usage: vivac push \"<title>\" --why \"<reason>\""))?;
let why = a.opt("why").ok_or_else(|| {
Failure::usage(
"Missing --why. A detour with no reason is exactly the failure this\n \
exists to attack: in a month nobody will know why.",
)
})?;
Ok(Push {
title: title.to_string(),
why: why.to_string(),
kind: a.opt("type").map(str::to_string),
refs: a.list("ref"),
governs: a.list("governs"),
blocks: a.has("blocks"),
arms: a.list("arm"),
arm_dir: a.opt("arm-dir").map(str::to_string),
against: a.list("against"),
via_mcp: false,
root: a.has("root"),
parent: a.opt("parent").map(str::to_string),
})
}
}
pub struct Pop {
pub outcome: String,
pub next: Option<String>,
pub force: bool,
}
impl Pop {
pub fn from_args(a: &Args) -> Result<Pop, Failure> {
Ok(Pop {
outcome: a.positional(0).unwrap_or("").to_string(),
next: a.opt("next").map(str::to_string),
force: a.has("force"),
})
}
}
pub struct Done {
pub id: String,
pub outcome: String,
pub force: bool,
}
impl Done {
pub fn from_args(a: &Args) -> Result<Done, Failure> {
let id = a
.positional(0)
.ok_or_else(|| Failure::usage("usage: vivac done <id> [\"<outcome>\"] [--force]"))?;
Ok(Done {
id: id.to_string(),
outcome: a.positional(1).unwrap_or("").to_string(),
force: a.has("force"),
})
}
}
pub struct Park {
pub node: Option<String>,
pub reason: Option<String>,
pub until: Option<String>,
}
impl Park {
pub fn from_args(a: &Args) -> Result<Park, Failure> {
let until = match a.opt("until") {
Some(s) => Some(validate_until(s)?),
None => None,
};
Ok(Park {
node: a.positional(0).map(str::to_string),
reason: a.positional(1).map(str::to_string),
until,
})
}
}
pub(crate) fn validate_until(s: &str) -> Result<String, Failure> {
validate_day(s, "--until", "a park comes back on a day still ahead.")
}
fn validate_on(s: &str) -> Result<String, Failure> {
validate_day(s, "--on", "a review is set for a day still ahead.")
}
fn validate_day(s: &str, flag: &str, closing: &str) -> Result<String, Failure> {
if !crate::clock::is_civil_date(s) {
return Err(Failure::usage(format!(
"{flag} takes a date as YYYY-MM-DD."
)));
}
let today = crate::clock::today_local();
if s <= today.as_str() {
let why = if s == today {
"is today"
} else {
"has already passed"
};
return Err(Failure::usage(format!("{flag} {s} {why}; {closing}")));
}
Ok(s.to_string())
}
pub struct Add {
pub title: String,
pub parent: Option<String>,
pub kind: Option<String>,
pub why: String,
pub refs: Vec<String>,
pub governs: Vec<String>,
pub blocks: bool,
pub arms: Vec<String>,
pub arm_dir: Option<String>,
pub against: Vec<String>,
pub via_mcp: bool,
pub root: bool,
}
impl Add {
pub fn from_args(a: &Args) -> Result<Add, Failure> {
let title = a.positional(0).ok_or_else(|| {
Failure::usage(
"usage: vivac add \"<title>\" [--parent N | --root] [--why \"<reason>\"]",
)
})?;
Ok(Add {
title: title.to_string(),
parent: a.opt("parent").map(str::to_string),
kind: a.opt("type").map(str::to_string),
why: a.opt_or("why"),
refs: a.list("ref"),
governs: a.list("governs"),
blocks: a.has("blocks"),
arms: a.list("arm"),
arm_dir: a.opt("arm-dir").map(str::to_string),
against: a.list("against"),
via_mcp: false,
root: a.has("root"),
})
}
}
pub struct Note {
pub node: Option<String>,
pub note: Option<String>,
}
impl Note {
pub fn from_args(a: &Args) -> Result<Note, Failure> {
Ok(Note {
node: a.positional(0).map(str::to_string),
note: a.positional(1).map(str::to_string),
})
}
}
pub struct Block {
pub id: String,
pub off: bool,
}
impl Block {
pub fn from_args(a: &Args) -> Result<Block, Failure> {
let id = a
.positional(0)
.ok_or_else(|| Failure::usage("usage: vivac block <id> [--off]"))?;
Ok(Block {
id: id.to_string(),
off: a.has("off"),
})
}
}
pub struct Promote {
pub id: Option<String>,
}
impl Promote {
pub fn from_args(a: &Args) -> Result<Promote, Failure> {
Ok(Promote {
id: a.positional(0).map(str::to_string),
})
}
}
pub struct Abandon {
pub node: Option<String>,
pub reason: Option<String>,
pub rescue: Vec<String>,
pub cascade: bool,
}
impl Abandon {
pub fn from_args(a: &Args) -> Result<Abandon, Failure> {
Ok(Abandon {
node: a.positional(0).map(str::to_string),
reason: a.positional(1).map(str::to_string),
rescue: a.list("rescue"),
cascade: a.has("cascade"),
})
}
}
pub struct Focus {
pub id: String,
pub reopen: bool,
}
impl Focus {
pub fn from_args(a: &Args) -> Result<Focus, Failure> {
let id = a
.positional(0)
.ok_or_else(|| Failure::usage("usage: vivac focus <id> [--reopen]"))?;
Ok(Focus {
id: id.to_string(),
reopen: a.has("reopen"),
})
}
}
pub struct Flag {
pub id: String,
pub flag: String,
pub off: bool,
pub why: Option<String>,
pub on: Option<String>,
}
impl Flag {
pub fn from_args(a: &Args) -> Result<Flag, Failure> {
let (Some(sid), Some(sb)) = (a.positional(0), a.positional(1)) else {
return Err(Failure::usage(
"usage: vivac flag <id> <flag> --why \"<reason>\" [--on YYYY-MM-DD] | --off\n\n \
Flags: suspect, review, stale",
));
};
let on = match a.opt("on") {
Some(s) => Some(validate_on(s)?),
None => None,
};
if on.is_some() && a.has("off") {
return Err(Failure::usage(
"--on and --off do not go together: --off clears the flag.",
));
}
Ok(Flag {
id: sid.to_string(),
flag: sb.to_string(),
off: a.has("off"),
why: a.opt("why").map(str::to_string),
on,
})
}
}
pub struct Decide {
pub title: String,
pub parent: Option<String>,
pub reason: String,
pub alternatives: Vec<String>,
pub supersedes: Option<String>,
pub refs: Vec<String>,
pub governs: Vec<String>,
pub blocks: bool,
pub against: Vec<String>,
pub root: bool,
}
impl Decide {
pub fn from_args(a: &Args) -> Result<Decide, Failure> {
let title = a.positional(0).ok_or_else(|| {
Failure::usage(
"usage: vivac decide \"<title>\" --reason \"<r>\" [--alternative X] [--supersedes d9]",
)
})?;
let reason = a.opt("reason").ok_or_else(|| {
Failure::usage(
"Missing --reason. A decision with no reason is a datum, not a decision.",
)
})?;
Ok(Decide {
title: title.to_string(),
parent: a.opt("parent").map(str::to_string),
reason: reason.to_string(),
alternatives: a.list("alternative"),
supersedes: a.opt("supersedes").map(str::to_string),
refs: a.list("ref"),
governs: a.list("governs"),
blocks: a.has("blocks"),
against: a.list("against"),
root: a.has("root"),
})
}
}
pub struct Declare {
pub id: Option<String>,
pub against: Vec<String>,
}
impl Declare {
pub fn from_args(a: &Args) -> Result<Declare, Failure> {
Ok(Declare {
id: a.positional(0).map(str::to_string),
against: a.list("against"),
})
}
}
pub struct Save {
pub label: String,
pub next: String,
}
impl Save {
pub fn from_args(a: &Args) -> Result<Save, Failure> {
Ok(Save {
label: a.positional(0).unwrap_or("").to_string(),
next: a.opt_or("next"),
})
}
}
pub struct Restore {
pub vivac: String,
}
impl Restore {
pub fn from_args(a: &Args) -> Result<Restore, Failure> {
let s = a
.positional(0)
.ok_or_else(|| Failure::usage("usage: vivac restore <v>"))?;
Ok(Restore {
vivac: s.to_string(),
})
}
}
pub struct Arm {
pub id: String,
pub command: String,
pub dir: Option<String>,
pub off: bool,
pub via_mcp: bool,
}
impl Arm {
pub fn from_args(a: &Args) -> Result<Arm, Failure> {
let (Some(id), Some(command)) = (a.positional(0), a.positional(1)) else {
return Err(Failure::usage(
"usage: vivac arm <rule> \"<command>\" --dir <dir> [--off]",
));
};
Ok(Arm {
id: id.to_string(),
command: command.to_string(),
dir: a.opt("dir").map(str::to_string),
off: a.has("off"),
via_mcp: false,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::Ticking;
fn args(words: &[&str]) -> Args {
Args::parse(words.iter().map(|s| s.to_string())).unwrap()
}
fn today_is_2026_08_31() -> Ticking {
Ticking::start(20_696 * 86_400)
}
#[test]
fn no_until_is_none() {
let p = Park::from_args(&args(&["t1", "waiting"])).unwrap();
assert_eq!(p.until, None);
}
#[test]
fn a_date_strictly_after_today_is_accepted() {
let _t = today_is_2026_08_31();
let p = Park::from_args(&args(&["t1", "waiting", "--until", "2026-09-01"])).unwrap();
assert_eq!(p.until, Some("2026-09-01".to_string()));
}
fn refusal(r: Result<Park, Failure>) -> String {
match r {
Ok(_) => panic!("expected a refusal, got Ok"),
Err(Failure::Usage(m)) => m,
Err(other) => panic!("expected a usage failure, got {other:?}"),
}
}
#[test]
fn a_past_date_is_refused() {
let _t = today_is_2026_08_31();
let m = refusal(Park::from_args(&args(&[
"t1",
"waiting",
"--until",
"2026-08-30",
])));
assert!(m.contains("has already passed"), "{m}");
}
#[test]
fn a_same_day_date_is_refused() {
let _t = today_is_2026_08_31();
let m = refusal(Park::from_args(&args(&[
"t1",
"waiting",
"--until",
"2026-08-31",
])));
assert!(m.contains("is today"), "{m}");
}
#[test]
fn a_short_form_is_refused() {
let _t = today_is_2026_08_31();
let m = refusal(Park::from_args(&args(&[
"t1", "waiting", "--until", "2026-9-1",
])));
assert!(m.contains("YYYY-MM-DD"), "{m}");
}
#[test]
fn an_invalid_calendar_date_is_refused() {
let _t = today_is_2026_08_31();
let m = refusal(Park::from_args(&args(&[
"t1",
"waiting",
"--until",
"2026-02-30",
])));
assert!(m.contains("YYYY-MM-DD"), "{m}");
}
#[test]
fn a_relative_form_is_refused() {
let _t = today_is_2026_08_31();
let m = refusal(Park::from_args(&args(&[
"t1", "waiting", "--until", "tomorrow",
])));
assert!(m.contains("YYYY-MM-DD"), "{m}");
}
fn flag_refusal(r: Result<Flag, Failure>) -> String {
match r {
Ok(_) => panic!("expected a refusal, got Ok"),
Err(Failure::Usage(m)) => m,
Err(other) => panic!("expected a usage failure, got {other:?}"),
}
}
#[test]
fn no_on_is_none() {
let p = Flag::from_args(&args(&["t1", "review", "--why", "look again"])).unwrap();
assert_eq!(p.on, None);
}
#[test]
fn an_on_date_strictly_after_today_is_accepted() {
let _t = today_is_2026_08_31();
let p = Flag::from_args(&args(&[
"t1",
"review",
"--why",
"look again",
"--on",
"2026-09-01",
]))
.unwrap();
assert_eq!(p.on, Some("2026-09-01".to_string()));
}
#[test]
fn an_on_date_that_has_passed_is_refused_in_its_own_words() {
let _t = today_is_2026_08_31();
let m = flag_refusal(Flag::from_args(&args(&[
"t1",
"review",
"--why",
"x",
"--on",
"2026-08-30",
])));
assert_eq!(
m.trim(),
"--on 2026-08-30 has already passed; a review is set for a day still ahead."
);
}
#[test]
fn an_on_date_of_today_is_refused_in_its_own_words() {
let _t = today_is_2026_08_31();
let m = flag_refusal(Flag::from_args(&args(&[
"t1",
"review",
"--why",
"x",
"--on",
"2026-08-31",
])));
assert_eq!(
m.trim(),
"--on 2026-08-31 is today; a review is set for a day still ahead."
);
}
#[test]
fn a_malformed_on_date_is_refused() {
let _t = today_is_2026_08_31();
let m = flag_refusal(Flag::from_args(&args(&[
"t1", "review", "--why", "x", "--on", "2026-9-1",
])));
assert_eq!(m.trim(), "--on takes a date as YYYY-MM-DD.");
}
#[test]
fn on_beside_off_is_refused() {
let _t = today_is_2026_08_31();
let m = flag_refusal(Flag::from_args(&args(&[
"t1",
"review",
"--off",
"--on",
"2026-09-01",
])));
assert_eq!(
m.trim(),
"--on and --off do not go together: --off clears the flag."
);
}
}