pub mod model;
pub mod mv;
pub mod records;
pub mod table;
use std::io::Read;
use clap::Subcommand;
use crate::ledger::store::{self, check_docs_path, LedgerStore};
use model::{Category, ListKind, Mission};
#[derive(Subcommand)]
pub enum MissionCommands {
#[command(
about = "Create a mission in the local store; an administrative mission starts in-progress"
)]
Create {
#[arg(help = "Mission ID, for example M-TSK-SCHEMA")]
id: String,
#[arg(allow_hyphen_values = true, help = "One-line name")]
name: String,
#[arg(long, help = "operational (the default) or administrative")]
category: Option<String>,
#[arg(long, help = "ID of the parent mission")]
parent: Option<String>,
},
#[command(
about = "Set a field: name, status, purpose or decision-authority; the value is read from stdin when it is -. Constraints and out-of-scope are lists: see the constraint and out-of-scope commands"
)]
Set {
id: String,
field: String,
#[arg(allow_hyphen_values = true)]
value: String,
},
#[command(about = "Record that a mission is blocked by another; refuses a cycle")]
Block {
id: String,
#[arg(help = "ID of the blocking mission")]
by_id: String,
},
#[command(about = "Remove a block")]
Unblock { id: String, by_id: String },
#[command(about = "Add, tick, remove and list objective outcomes")]
Objective {
#[command(subcommand)]
action: ObjectiveCommands,
},
#[command(about = "Add, remove and list the constraints of a mission, one line each")]
Constraint {
#[command(subcommand)]
action: ItemCommands,
},
#[command(
name = "out-of-scope",
about = "Add, remove and list what is out of scope for a mission, one line each"
)]
OutOfScope {
#[command(subcommand)]
action: ItemCommands,
},
#[command(about = "Link a document on the tsk/docs branch to a mission")]
Link {
id: String,
#[arg(help = "Document path, as tsk ledger doc list prints it")]
path: String,
#[arg(long, help = "intelligence, report or design")]
role: String,
},
#[command(about = "Remove a document link")]
Unlink { id: String, path: String },
#[command(
about = "Rename a mission: only its ID changes, and every record, thread, block and parent follows. Linked documents named for the mission are renamed too. Prints one line per change, then one line for each record whose text names the old ID, which is not changed. A local write"
)]
Mv {
#[arg(help = "The mission ID now")]
from: String,
#[arg(help = "The new mission ID; it must not be the ID of a mission")]
to: String,
#[arg(long, help = "Print the lines and change nothing")]
dry_run: bool,
},
#[command(
about = "List missions, one per line: id, status, blocked-by, parent, name; or one mission with an id. The columns are aligned with spaces; --porcelain prints them tab-separated; --json prints the full records"
)]
List {
id: Option<String>,
#[arg(long, help = "Print the full record as JSON")]
json: bool,
#[arg(
long,
help = "Print tab-separated columns, the stable form for scripts, instead of aligned columns"
)]
porcelain: bool,
},
}
#[derive(Subcommand)]
pub enum ObjectiveCommands {
#[command(about = "Add an outcome; prints its number")]
Add {
id: String,
#[arg(allow_hyphen_values = true)]
outcome: String,
},
#[command(about = "Mark outcome n as met")]
Done { id: String, n: i64 },
#[command(about = "Mark outcome n as not met")]
Undone { id: String, n: i64 },
#[command(about = "Remove outcome n; its number is not reused")]
Remove { id: String, n: i64 },
#[command(about = "List the outcomes: n, met or open, outcome")]
List { id: String },
}
#[derive(Subcommand)]
pub enum ItemCommands {
#[command(about = "Add an item; prints its number")]
Add {
id: String,
#[arg(allow_hyphen_values = true, help = "One-line text")]
text: String,
},
#[command(about = "Remove item n; its number is not reused")]
Remove { id: String, n: i64 },
#[command(about = "List the items: n, text")]
List { id: String },
}
pub fn run(action: MissionCommands) -> Result<(), String> {
let cwd = std::env::current_dir()
.map_err(|e| format!("error: could not read the current directory: {}", e))?;
let store = store::open(&cwd)?;
run_with(store.as_ref(), action)
}
fn read_stdin() -> Result<String, String> {
let mut text = String::new();
std::io::stdin()
.read_to_string(&mut text)
.map_err(|e| format!("error: could not read stdin: {}", e))?;
Ok(text)
}
pub(super) fn change(
store: &dyn LedgerStore,
apply: impl FnOnce(&[Mission]) -> Result<Mission, String>,
) -> Result<Mission, String> {
let missions = store.missions_load()?;
let updated = apply(&missions)?;
store.mission_save(&updated)?;
Ok(updated)
}
pub fn run_with(store: &dyn LedgerStore, action: MissionCommands) -> Result<(), String> {
match action {
MissionCommands::Create {
id,
name,
category,
parent,
} => {
let category = match category {
Some(value) => Category::parse(&value)?,
None => Category::Operational,
};
change(store, |missions| {
model::create(missions, &id, &name, category, parent.as_deref())
})?;
}
MissionCommands::Set { id, field, value } => {
let value = if value == "-" {
let text = read_stdin()?;
if field == "name" || field == "status" {
model::strip_line_break(&text).to_string()
} else {
text
}
} else {
value
};
change(store, |missions| model::set(missions, &id, &field, &value))?;
}
MissionCommands::Block { id, by_id } => {
change(store, |missions| model::block(missions, &id, &by_id))?;
}
MissionCommands::Unblock { id, by_id } => {
change(store, |missions| model::unblock(missions, &id, &by_id))?;
}
MissionCommands::Objective { action } => objective(store, action)?,
MissionCommands::Constraint { action } => items(store, ListKind::Constraint, action)?,
MissionCommands::OutOfScope { action } => items(store, ListKind::OutOfScope, action)?,
MissionCommands::Link { id, path, role } => {
let path = check_docs_path(&path).unwrap_or(path);
let exists = store.doc_exists(&path)?;
change(store, |missions| {
model::link(missions, &id, &path, &role, exists)
})?;
}
MissionCommands::Unlink { id, path } => {
let path = check_docs_path(&path).unwrap_or(path);
change(store, |missions| model::unlink(missions, &id, &path))?;
}
MissionCommands::Mv { from, to, dry_run } => mv::mission_mv(store, &from, &to, dry_run)?,
MissionCommands::List {
id,
json,
porcelain,
} => list(store, id.as_deref(), json, porcelain)?,
}
Ok(())
}
fn objective(store: &dyn LedgerStore, action: ObjectiveCommands) -> Result<(), String> {
match action {
ObjectiveCommands::Add { id, outcome } => {
let saved = change(store, |missions| {
model::objective_add(missions, &id, &outcome)
})?;
println!("{}", saved.next_objective - 1);
}
ObjectiveCommands::Done { id, n } => {
change(store, |missions| {
model::objective_met(missions, &id, n, true)
})?;
}
ObjectiveCommands::Undone { id, n } => {
change(store, |missions| {
model::objective_met(missions, &id, n, false)
})?;
}
ObjectiveCommands::Remove { id, n } => {
change(store, |missions| model::objective_remove(missions, &id, n))?;
}
ObjectiveCommands::List { id } => {
let missions = store.missions_load()?;
let mission = missions
.iter()
.find(|m| m.id == id)
.ok_or_else(|| format!("error: no such mission: {}", id))?;
for outcome in &mission.objective {
println!("{}", model::objective_line(outcome));
}
}
}
Ok(())
}
fn items(store: &dyn LedgerStore, kind: ListKind, action: ItemCommands) -> Result<(), String> {
match action {
ItemCommands::Add { id, text } => {
let saved = change(store, |missions| {
model::item_add(missions, &id, kind, &text)
})?;
let next = match kind {
ListKind::Constraint => saved.next_constraint,
ListKind::OutOfScope => saved.next_out_of_scope,
};
println!("{}", next - 1);
}
ItemCommands::Remove { id, n } => {
change(store, |missions| model::item_remove(missions, &id, kind, n))?;
}
ItemCommands::List { id } => {
let missions = store.missions_load()?;
let mission = model::find(&missions, &id)?;
let shown = match kind {
ListKind::Constraint => &mission.constraints,
ListKind::OutOfScope => &mission.out_of_scope,
};
for item in shown {
println!("{}", model::item_line(item));
}
}
}
Ok(())
}
fn list(
store: &dyn LedgerStore,
id: Option<&str>,
json: bool,
porcelain: bool,
) -> Result<(), String> {
let missions = store.missions_load()?;
let shown: Vec<&Mission> = match id {
Some(id) => vec![missions
.iter()
.find(|m| m.id == id)
.ok_or_else(|| format!("error: no such mission: {}", id))?],
None => missions.iter().collect(),
};
if json {
let records: Vec<model::JsonMission> =
shown.iter().map(|m| model::to_json(&missions, m)).collect();
let printed = match (id, records.first()) {
(Some(_), Some(record)) => serde_json::to_string(record),
_ => serde_json::to_string(&records),
};
println!(
"{}",
printed.map_err(|e| format!("error: could not print JSON: {}", e))?
);
} else {
let lines = shown
.into_iter()
.map(|mission| model::list_line(&missions, mission))
.collect();
table::print_lines(lines, porcelain);
}
Ok(())
}