#![forbid(unsafe_code)]
use std::collections::BTreeMap;
use std::path::PathBuf;
use wvq_command_bus::{
BaselineCommand, Command, ContextCommand, DebtCommand, DoctorCommand, ExplainCommand,
IngestCassetteCommand, IngestJournalCommand, InitCommand, LiveService, ModelCommand,
PlanCommand, QualityService, RecordCommand, RecoveryCommand, RunCommand, SelectCommand,
SpecCommand, VerifyCommand, dispatch,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliRequest {
pub repo: PathBuf,
pub command: Command,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliOutput {
pub code: i32,
pub stdout: String,
pub stderr: String,
}
#[must_use]
pub fn usage() -> String {
"wvq — Weavatrix Quality
Usage:
wvq [--repo PATH] init [--force true|false]
wvq [--repo PATH] doctor
wvq [--repo PATH] spec validate [--change ID]
wvq [--repo PATH] spec seal [--change ID]
wvq [--repo PATH] analyze [--change ID] [--purpose spec|implementation|review] [--token-budget N]
wvq [--repo PATH] debt [--change ID] [--base REF] [--head REF|WORKTREE]
wvq [--repo PATH] select [--change ID] [--base REF] [--head REF|WORKTREE]
wvq [--repo PATH] recover [--change ID] [--base REF] [--head REF|WORKTREE]
wvq [--repo PATH] record [--change ID] [--base REF] [--head REF|WORKTREE] [--route /PATH] [--idle-ms N] [--max-events N] [--headless true|false] [--fixtures-json JSON]
wvq [--repo PATH] ingest-journal --file PATH [--change ID] [--base REF] [--head REF|WORKTREE]
wvq [--repo PATH] ingest-cassette --file PATH --origin URL
wvq [--repo PATH] baseline [--change ID] [--base REF] [--head REF|WORKTREE] [--decision observed_only]
wvq [--repo PATH] model [--change ID] --kind planning|runtime|browser_escape|vision --prompt TEXT
wvq [--repo PATH] run [--change ID] [--base REF] [--head REF|WORKTREE] [--scope impacted|all] [--evidence-policy standard|minimal|none]
wvq [--repo PATH] verify [--change ID] [--observe-only true|false]
wvq [--repo PATH] explain <id>
wvq [--repo PATH] plan [--change ID]
wvq [--repo PATH] status
"
.to_owned()
}
pub fn parse_args(args: &[String]) -> Result<CliRequest, String> {
if args.iter().any(|item| item == "--help" || item == "-h") {
return Err(usage());
}
let mut flags = BTreeMap::new();
let mut positionals = Vec::new();
let mut index = 0;
while index < args.len() {
let item = &args[index];
if let Some(name) = item.strip_prefix("--") {
index += 1;
let value = args
.get(index)
.ok_or_else(|| format!("flag --{name} requires a value"))?;
if flags.insert(name.to_owned(), value.clone()).is_some() {
return Err(format!("flag --{name} was supplied more than once"));
}
index += 1;
continue;
}
positionals.push(item.clone());
index += 1;
}
if let Some(allowed) = allowed_flags(&positionals)
&& let Some(unknown) = flags.keys().find(|name| !allowed.contains(&name.as_str()))
{
return Err(format!(
"unknown flag --{unknown} for {}",
positionals.join(" ")
));
}
let repo = flags
.get("repo")
.map_or_else(|| PathBuf::from("."), PathBuf::from);
let command = parse_command(&flags, &positionals)?;
Ok(CliRequest { repo, command })
}
fn parse_command(
flags: &BTreeMap<String, String>,
positionals: &[String],
) -> Result<Command, String> {
let (change, base, head) = revision_flags(flags);
match positionals {
[spec, action] if spec == "spec" && action == "validate" => {
Ok(Command::SpecValidate(SpecCommand { change }))
}
[spec, action] if spec == "spec" && action == "seal" => {
Ok(Command::SpecSeal(SpecCommand { change }))
}
[cmd] if cmd == "analyze" => Ok(Command::Analyze(ContextCommand {
change,
purpose: flags
.get("purpose")
.cloned()
.unwrap_or_else(|| "implementation".to_owned()),
token_budget: parse_budget(flags.get("token-budget"))?,
})),
[cmd] if cmd == "debt" => Ok(Command::Debt(DebtCommand { change, base, head })),
[cmd] if cmd == "select" => Ok(Command::Select(SelectCommand { change, base, head })),
[cmd] if cmd == "recover" => Ok(Command::Recovery(RecoveryCommand { change, base, head })),
[cmd] if cmd == "record" => parse_record_command(change, base, head, flags),
[cmd] if cmd == "ingest-journal" => Ok(Command::IngestJournal(IngestJournalCommand {
change,
base,
head,
journal: read_bounded_payload(
flags.get("file"),
flags.get("journal"),
"journal",
1_048_576,
)?,
})),
[cmd] if cmd == "ingest-cassette" => parse_ingest_cassette(flags),
[cmd] if cmd == "baseline" => Ok(Command::Baseline(BaselineCommand {
change,
base,
head,
decision: flags
.get("decision")
.cloned()
.unwrap_or_else(|| "observed_only".to_owned()),
})),
[cmd] if cmd == "model" => Ok(Command::Model(ModelCommand {
change,
kind: required_flag(flags, "kind")?,
prompt: required_flag(flags, "prompt")?,
})),
[cmd] if cmd == "run" => Ok(Command::Run(RunCommand {
change,
scope: flags
.get("scope")
.cloned()
.unwrap_or_else(|| "impacted".to_owned()),
evidence_policy: flags
.get("evidence-policy")
.cloned()
.unwrap_or_else(|| "standard".to_owned()),
base,
head,
})),
[cmd] if cmd == "verify" => Ok(Command::Verify(VerifyCommand {
change,
observe_only: flags
.get("observe-only")
.map(|value| parse_bool_flag(value, "observe-only"))
.transpose()?
.unwrap_or(false),
})),
[cmd] if cmd == "init" => Ok(Command::Init(InitCommand {
force: flags
.get("force")
.map(|value| parse_bool_flag(value, "force"))
.transpose()?
.unwrap_or(false),
})),
[cmd] if cmd == "doctor" => Ok(Command::Doctor(DoctorCommand {})),
[cmd] if cmd == "plan" => Ok(Command::Plan(PlanCommand { change })),
[cmd] if cmd == "status" => Ok(Command::Status(wvq_command_bus::StatusCommand {
run_id: None,
})),
[cmd, id] if cmd == "explain" => Ok(Command::Explain(ExplainCommand { id: id.clone() })),
[] => Err(usage()),
other => Err(format!(
"unknown command `{}`\n{}",
other.join(" "),
usage()
)),
}
}
fn revision_flags(flags: &BTreeMap<String, String>) -> (String, String, String) {
(
flags
.get("change")
.cloned()
.unwrap_or_else(|| "current".to_owned()),
flags
.get("base")
.cloned()
.unwrap_or_else(|| "HEAD".to_owned()),
flags
.get("head")
.cloned()
.unwrap_or_else(|| "WORKTREE".to_owned()),
)
}
fn parse_record_command(
change: String,
base: String,
head: String,
flags: &BTreeMap<String, String>,
) -> Result<Command, String> {
Ok(Command::Record(RecordCommand {
change,
base,
head,
route: flags
.get("route")
.cloned()
.unwrap_or_else(|| "/".to_owned()),
fixture_values: parse_fixtures(flags.get("fixtures-json"))?,
idle_timeout_ms: parse_u64_flag(flags.get("idle-ms"), 3_000, "idle-ms")?,
max_events: u32::try_from(parse_u64_flag(flags.get("max-events"), 200, "max-events")?)
.map_err(|_| "invalid --max-events value".to_owned())?,
headless: flags
.get("headless")
.map(|value| parse_bool_flag(value, "headless"))
.transpose()?,
}))
}
fn parse_ingest_cassette(flags: &BTreeMap<String, String>) -> Result<Command, String> {
Ok(Command::IngestCassette(IngestCassetteCommand {
origin: required_flag(flags, "origin")?,
har: read_bounded_payload(flags.get("file"), flags.get("har"), "HAR", 8_388_608)?,
}))
}
fn required_flag(flags: &BTreeMap<String, String>, name: &str) -> Result<String, String> {
flags
.get(name)
.filter(|value| !value.is_empty())
.cloned()
.ok_or_else(|| format!("flag --{name} is required"))
}
fn allowed_flags(positionals: &[String]) -> Option<&'static [&'static str]> {
match positionals {
[spec, action] if spec == "spec" && matches!(action.as_str(), "validate" | "seal") => {
Some(&["repo", "change"])
}
[cmd] if cmd == "analyze" => Some(&["repo", "change", "purpose", "token-budget"]),
[cmd] if matches!(cmd.as_str(), "debt" | "select" | "recover") => {
Some(&["repo", "change", "base", "head"])
}
[cmd] if cmd == "record" => Some(&[
"repo",
"change",
"base",
"head",
"route",
"idle-ms",
"max-events",
"headless",
"fixtures-json",
]),
[cmd] if cmd == "ingest-journal" => {
Some(&["repo", "change", "base", "head", "file", "journal"])
}
[cmd] if cmd == "ingest-cassette" => Some(&["repo", "file", "har", "origin"]),
[cmd] if cmd == "baseline" => Some(&["repo", "change", "base", "head", "decision"]),
[cmd] if cmd == "verify" => Some(&["repo", "change", "observe-only"]),
[cmd] if cmd == "plan" => Some(&["repo", "change"]),
[cmd] if cmd == "init" => Some(&["repo", "force"]),
[cmd] if matches!(cmd.as_str(), "doctor" | "status") => Some(&["repo"]),
[cmd] if cmd == "model" => Some(&["repo", "change", "kind", "prompt"]),
[cmd] if cmd == "run" => {
Some(&["repo", "change", "base", "head", "scope", "evidence-policy"])
}
[cmd, _] if cmd == "explain" => Some(&["repo"]),
_ => None,
}
}
fn parse_budget(raw: Option<&String>) -> Result<u64, String> {
match raw {
None => Ok(4_000),
Some(text) => text
.parse()
.map_err(|_| format!("invalid --token-budget {text}")),
}
}
fn parse_u64_flag(raw: Option<&String>, fallback: u64, name: &str) -> Result<u64, String> {
raw.map_or(Ok(fallback), |text| {
text.parse().map_err(|_| format!("invalid --{name} {text}"))
})
}
fn parse_bool_flag(raw: &str, name: &str) -> Result<bool, String> {
match raw {
"true" => Ok(true),
"false" => Ok(false),
value => Err(format!("invalid --{name} {value}; expected true or false")),
}
}
fn parse_fixtures(raw: Option<&String>) -> Result<BTreeMap<String, String>, String> {
raw.map_or_else(
|| Ok(BTreeMap::new()),
|text| {
serde_json::from_str(text)
.map_err(|err| format!("invalid --fixtures-json object: {err}"))
},
)
}
fn read_bounded_payload(
file: Option<&String>,
inline: Option<&String>,
name: &str,
max_bytes: u64,
) -> Result<String, String> {
match (file, inline) {
(Some(_), Some(_)) => Err(format!("pass exactly one of --file or --{name}")),
(None, None) => Err("flag --file is required".into()),
(None, Some(body)) => {
if u64::try_from(body.len()).unwrap_or(u64::MAX) > max_bytes {
return Err(format!("{name} exceeds {max_bytes} bytes"));
}
Ok(body.clone())
}
(Some(path), None) => {
let metadata = std::fs::metadata(path)
.map_err(|err| format!("cannot read {name} {path}: {err}"))?;
if metadata.len() > max_bytes {
return Err(format!("{name} exceeds {max_bytes} bytes"));
}
std::fs::read_to_string(path).map_err(|err| format!("cannot read {name} {path}: {err}"))
}
}
}
#[must_use]
pub fn execute(request: &CliRequest, service: &dyn QualityService) -> CliOutput {
match dispatch(service, request.command.clone()) {
Ok(reply) => {
let code = reply.verify_exit_code().unwrap_or(0);
match serde_json::to_string_pretty(&reply) {
Ok(stdout) => CliOutput {
code,
stdout: stdout + "\n",
stderr: String::new(),
},
Err(err) => CliOutput {
code: 1,
stdout: String::new(),
stderr: format!("{err}\n"),
},
}
}
Err(err) => CliOutput {
code: 1,
stdout: String::new(),
stderr: format!("{err}\n"),
},
}
}
#[must_use]
pub fn run(args: &[String]) -> CliOutput {
match parse_args(args) {
Ok(request) => {
let service = LiveService::new(&request.repo);
execute(&request, &service)
}
Err(message) => usage_output(message),
}
}
#[must_use]
pub fn run_with(args: &[String], service: &dyn QualityService) -> CliOutput {
match parse_args(args) {
Ok(request) => execute(&request, service),
Err(message) => usage_output(message),
}
}
fn usage_output(message: String) -> CliOutput {
if message.starts_with("wvq —") {
CliOutput {
code: 0,
stdout: message,
stderr: String::new(),
}
} else {
CliOutput {
code: 1,
stdout: String::new(),
stderr: message,
}
}
}