use anyhow::Result;
use dialoguer::{theme::ColorfulTheme, Input, Select};
use std::path::PathBuf;
use crate::cli::{
AddArgs, CoverageArgs, DeleteArgs, DiffArgs, DoctorArgs, ExportArgs, ExportFormat, ListArgs,
NextArgs, ShowArgs, StaleArgs, StatusArgs, UpdateArgs, ValidateArgs, VersionArgs,
};
use crate::commands;
use crate::storage::load_resolved;
pub const MENU: &[&str] = &[
"Browse / view (list + show)",
"Status",
"Next requirement to work on",
"Add",
"Update",
"Link",
"Delete (mark obsolete)",
"Validate project",
"Coverage report",
"Stale report",
"Doctor (setup audit)",
"Diff between git refs",
"Audit (git signature trail)",
"Export to markdown (stdout)",
"Version",
"Quit",
];
pub fn run(file: &Option<PathBuf>) -> Result<()> {
let theme = ColorfulTheme::default();
loop {
let (_, project) = load_resolved(file)?;
let header = format!(
"req — {} ({} requirements)",
project.name,
project.requirements.len()
);
println!("\n{}", header);
println!("{}", "=".repeat(header.len()));
let idx = Select::with_theme(&theme)
.with_prompt("Action")
.items(MENU)
.default(0)
.interact()?;
let action = match dispatch(idx, file, &project, &theme) {
Ok(()) => continue,
Err(e) if e.to_string() == "__quit__" => return Ok(()),
Err(e) => Err(e),
};
action?;
}
}
fn dispatch(
idx: usize,
file: &Option<PathBuf>,
project: &crate::model::Project,
theme: &ColorfulTheme,
) -> Result<()> {
match MENU[idx] {
"Browse / view (list + show)" => browse(file, project),
"Status" => commands::status::run(StatusArgs { json: false }, file),
"Next requirement to work on" => commands::next::run(default_next(), file),
"Add" => commands::add::run(default_add(), file),
"Update" => {
if let Some(id) = pick_id(theme, project)? {
commands::update::run(default_update(id), file)?;
}
Ok(())
}
"Link" => link_flow(file, project, theme),
"Delete (mark obsolete)" => {
if let Some(id) = pick_id(theme, project)? {
commands::delete::run(
DeleteArgs {
id,
hard: false,
reason: None,
json: false,
},
file,
)?;
}
Ok(())
}
"Validate project" => commands::validate_cmd::run(ValidateArgs { json: false }, file),
"Coverage report" => commands::coverage::run(default_coverage(), file),
"Stale report" => commands::stale::run(default_stale(), file),
"Doctor (setup audit)" => commands::doctor::run(DoctorArgs { json: false }),
"Diff between git refs" => diff_flow(file, theme),
"Audit (git signature trail)" => audit_flow(file),
"Export to markdown (stdout)" => commands::export::run(
ExportArgs {
format: ExportFormat::Markdown,
output: "-".into(),
},
file,
),
"Version" => commands::version::run(VersionArgs { json: false }),
"Quit" => Err(anyhow::anyhow!("__quit__")),
_ => Ok(()),
}
}
fn link_flow(
file: &Option<PathBuf>,
project: &crate::model::Project,
theme: &ColorfulTheme,
) -> Result<()> {
let from = match pick_id(theme, project)? {
Some(id) => id,
None => return Ok(()),
};
let to = match pick_id(theme, project)? {
Some(id) => id,
None => return Ok(()),
};
let kinds = ["parent", "depends-on", "refines", "conflicts", "verifies"];
let idx = Select::with_theme(theme)
.with_prompt("Link kind")
.items(&kinds)
.default(0)
.interact()?;
let kind = match kinds[idx] {
"parent" => crate::cli::LinkKindArg::Parent,
"depends-on" => crate::cli::LinkKindArg::DependsOn,
"refines" => crate::cli::LinkKindArg::Refines,
"conflicts" => crate::cli::LinkKindArg::Conflicts,
_ => crate::cli::LinkKindArg::Verifies,
};
commands::link::run(
crate::cli::LinkArgs {
from,
to,
kind,
remove: false,
json: false,
},
file,
)
}
fn diff_flow(file: &Option<PathBuf>, theme: &ColorfulTheme) -> Result<()> {
let spec: String = Input::with_theme(theme)
.with_prompt("Git diff spec (e.g. origin/main..HEAD)")
.default("HEAD~1..HEAD".to_string())
.interact_text()?;
commands::diff::run(DiffArgs { spec, json: false }, file)
}
fn audit_flow(file: &Option<PathBuf>) -> Result<()> {
use crate::cli::AuditArgs;
commands::audit::run(
AuditArgs {
limit: 20,
gate: false,
require_good_signature: false,
required_signers: Vec::new(),
json: false,
},
file,
)
}
fn browse(file: &Option<PathBuf>, project: &crate::model::Project) -> Result<()> {
if project.requirements.is_empty() {
println!("(no requirements yet)");
return Ok(());
}
let theme = ColorfulTheme::default();
let ids: Vec<String> = project.requirements.keys().cloned().collect();
let labels: Vec<String> = ids
.iter()
.map(|id| {
let r = &project.requirements[id];
format!("{:<10} [{}] {}", id, r.status.as_str(), r.title)
})
.collect();
let pick = Select::with_theme(&theme)
.with_prompt("Pick a requirement")
.items(&labels)
.default(0)
.interact_opt()?;
if let Some(i) = pick {
commands::show::run(
ShowArgs {
id: ids[i].clone(),
json: false,
},
file,
)?;
}
Ok(())
}
fn pick_id(theme: &ColorfulTheme, project: &crate::model::Project) -> Result<Option<String>> {
if project.requirements.is_empty() {
println!("(no requirements yet)");
return Ok(None);
}
let ids: Vec<String> = project.requirements.keys().cloned().collect();
let labels: Vec<String> = ids
.iter()
.map(|id| format!("{} — {}", id, project.requirements[id].title))
.collect();
let pick = Select::with_theme(theme)
.items(&labels)
.default(0)
.interact_opt()?;
Ok(pick.map(|i| ids[i].clone()))
}
fn default_add() -> AddArgs {
AddArgs {
title: None,
statement: None,
rationale: None,
acceptance: vec![],
kind: None,
priority: None,
tag: vec![],
parent: None,
interactive: true,
json: false,
from_json: None,
}
}
fn default_update(id: String) -> UpdateArgs {
UpdateArgs {
id,
title: None,
statement: None,
rationale: None,
acceptance: None,
add_acceptance: vec![],
remove_acceptance: vec![],
kind: None,
priority: None,
status: None,
add_tag: vec![],
remove_tag: vec![],
reason: None,
json: false,
}
}
#[allow(dead_code)]
fn default_list() -> ListArgs {
ListArgs {
status: None,
include_obsolete: false,
kind: None,
priority: None,
tag: vec![],
query: None,
json: false,
}
}
fn default_next() -> NextArgs {
NextArgs {
status: None,
kind: None,
priority: None,
tag: vec![],
json: false,
}
}
fn default_coverage() -> CoverageArgs {
CoverageArgs {
path: PathBuf::from("."),
extensions: vec![],
unlinked_files: false,
by_file: false,
remap: vec![],
apply: false,
strict: false,
allow_orphans: vec![],
json: false,
}
}
fn default_stale() -> StaleArgs {
StaleArgs {
path: PathBuf::from("."),
only_stale: false,
json: false,
}
}