use std::io::{IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use serde_json::json;
use spectra_core::{analyze, artifact, change, config::Config, drift, instructions, schema, spec};
#[derive(Parser, Debug)]
#[command(
name = "spectra",
version,
about = "Open-source Spectra spec-driven CLI"
)]
struct Cli {
#[arg(long, global = true)]
no_color: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
Init {
#[arg(long)]
adopt: bool,
#[arg(long)]
json: bool,
},
Drift {
change: Option<String>,
#[arg(long)]
json: bool,
},
Analyze {
#[arg(help = "Change name (auto-detects if only one exists)")]
change: Option<String>,
#[arg(long)]
json: bool,
},
Status {
#[arg(long)]
change: Option<String>,
#[arg(long)]
schema: Option<String>,
#[arg(long)]
json: bool,
},
Instructions {
artifact: Option<String>,
#[arg(long)]
change: Option<String>,
#[arg(long)]
schema: Option<String>,
#[arg(long)]
json: bool,
#[arg(long)]
skill: Option<String>,
},
Validate {
change: Option<String>,
#[arg(long, conflicts_with = "change")]
changes: bool,
#[arg(long)]
strict: bool,
#[arg(long)]
json: bool,
},
List {
#[arg(long, conflicts_with_all = ["specs", "parked"])]
changes: bool,
#[arg(long, conflicts_with = "parked")]
specs: bool,
#[arg(long)]
parked: bool,
#[arg(long)]
json: bool,
},
Show {
item: String,
#[arg(long)]
json: bool,
},
Park {
change: String,
#[arg(long)]
json: bool,
},
Unpark {
change: String,
#[arg(long)]
json: bool,
},
New {
#[command(subcommand)]
target: NewTarget,
},
Task {
#[command(subcommand)]
target: TaskTarget,
},
Archive {
change: Option<String>,
#[arg(long)]
skip_specs: bool,
#[arg(long)]
mark_tasks_complete: bool,
},
}
#[derive(Subcommand, Debug)]
enum NewTarget {
Change {
name: String,
#[arg(long)]
json: bool,
},
Artifact {
#[arg(
value_name = "TYPE",
help = "Artifact type: proposal, design, tasks, spec"
)]
type_name: String,
#[arg(help = "Capability name (required for spec type)")]
capability: Option<String>,
#[arg(long, help = "Change name")]
change: Option<String>,
#[arg(long, help = "Read content from stdin instead of using empty template")]
stdin: bool,
#[arg(long, help = "Overwrite existing artifact")]
force: bool,
#[arg(long, help = "Output as JSON")]
json: bool,
},
}
#[derive(Subcommand, Debug)]
enum TaskTarget {
Done {
task_id: String,
#[arg(long)]
change: Option<String>,
#[arg(long)]
json: bool,
},
}
fn find_root(start: &Path) -> PathBuf {
let mut cur = Some(start);
while let Some(dir) = cur {
if dir.join(".spectra.yaml").exists() {
return dir.to_path_buf();
}
cur = dir.parent();
}
start.to_path_buf()
}
fn require_initialized(root: &Path) -> Result<Config> {
if !Config::is_initialized(root) {
anyhow::bail!("Not initialized. Run 'spectra init' first.");
}
Config::load(root)
}
fn init_json(outcome: &spectra_core::init::InitOutcome) -> serde_json::Value {
json!({
"root": outcome.root.to_string_lossy(),
"spec_dir": outcome.spec_dir,
"adopted": outcome.adopted,
"gitignore_updated": outcome.gitignore_updated,
})
}
fn cmd_init(root: &Path, adopt: bool, as_json: bool) -> Result<i32> {
let outcome = spectra_core::init::init_with_options(root, adopt)?;
if as_json {
println!("{}", serde_json::to_string_pretty(&init_json(&outcome))?);
} else if outcome.adopted {
println!(
"Adopted existing spectra project in {} (spec_dir: {}).",
outcome.root.display(),
outcome.spec_dir
);
} else {
println!(
"Initialized spectra project in {} (spec_dir: {}).",
outcome.root.display(),
outcome.spec_dir
);
if outcome.gitignore_updated {
println!("Added '.spectra/' to .gitignore.");
}
}
Ok(0)
}
fn cmd_drift(
cfg: &Config,
change_name: Option<&str>,
as_json: bool,
use_color: bool,
) -> Result<i32> {
let name = change::resolve(cfg, change_name)?;
let change = change::load(cfg, &name)?;
let report = drift::analyze(cfg, &change)?;
if as_json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_human(&report, use_color);
}
Ok(report.exit_code())
}
fn cmd_analyze(cfg: &Config, change_name: Option<&str>, as_json: bool) -> Result<i32> {
let name = change::resolve(cfg, change_name)?;
let report = analyze::analyze(cfg, &name)?;
if as_json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print!("{}", analyze::format_human(&report));
}
Ok(0)
}
fn cmd_validate(
cfg: &Config,
change_name: Option<&str>,
all_changes: bool,
strict: bool,
as_json: bool,
) -> Result<i32> {
let report = if all_changes {
spectra_core::validate::validate_all_active(cfg, strict)?
} else {
let name = change::resolve(cfg, change_name)?;
if change::try_load(cfg, &name)?.is_none() {
anyhow::bail!("Change '{name}' not found.");
}
spectra_core::validate::build_report(cfg, std::slice::from_ref(&name), strict)?
};
if as_json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_validate_human(&report);
}
Ok(i32::from(report.any_failed()))
}
fn print_validate_human(report: &spectra_core::validate::ValidateReport) {
for item in &report.items {
if item.valid {
println!("{:<45} OK", item.id);
} else {
let n = item.issues.len();
let noun = if n == 1 { "issue" } else { "issues" };
println!("{:<45} FAIL ({n} {noun})", item.id);
for issue in &item.issues {
println!(" {} {}: {}", issue.level, issue.path, issue.message);
}
}
}
let t = &report.summary.totals;
println!(
"\n{} passed, {} failed ({} total).",
t.passed, t.failed, t.total
);
}
fn color_enabled(no_color: bool) -> bool {
color_enabled_from(
no_color,
std::env::var_os("NO_COLOR").is_some(),
std::io::stdout().is_terminal(),
)
}
fn color_enabled_from(no_color: bool, no_color_env_set: bool, stdout_is_tty: bool) -> bool {
!no_color && !no_color_env_set && stdout_is_tty
}
fn colorize(text: &str, sgr_code: &str, enabled: bool) -> String {
if enabled {
format!("\x1b[{sgr_code}m{text}\x1b[0m")
} else {
text.to_string()
}
}
fn severity_sgr_code(severity: &str) -> &'static str {
match severity {
"light" => "32",
"medium" => "33",
_ => "31",
}
}
fn conclusion_line(severity: &str, use_color: bool) -> String {
let conclusion = match severity {
"light" => "Drift is minor — you can start work directly.",
"medium" => "The change has drifted moderately; refresh the plan before implementing.",
_ => "The change has drifted heavily; the old plan likely no longer fits — archive or restart.",
};
colorize(conclusion, severity_sgr_code(severity), use_color)
}
fn print_human(r: &drift::DriftReport, use_color: bool) {
println!("## Drift Report: {}\n", r.change_id);
println!("{}\n", conclusion_line(&r.severity, use_color));
let dim = |k: &str| r.dimensions.iter().find(|d| format!("{:?}", d.kind) == k);
let time = dim("Time").map(|d| d.status.as_str()).unwrap_or("-");
let design = if r.broken_anchors.is_empty() {
"No broken references".to_string()
} else {
format!("{} broken", r.broken_anchors.len())
};
let tasks = if r.tasks_blocked_external.is_empty() && r.tasks_maybe_resolved.is_empty() {
"No task collisions".to_string()
} else {
format!(
"{} blocked, {} maybe-done",
r.tasks_blocked_external.len(),
r.tasks_maybe_resolved.len()
)
};
println!("| Dimension | Status |");
println!("|-------------------|---------------------------------------|");
println!("| Time | {time:<37} |");
println!("| Design references | {design:<37} |");
println!("| Pending tasks | {tasks:<37} |");
println!(
"| Overall | {:<37} |",
format!("{}, total score {}", r.severity, r.total_score)
);
println!("\n### Recommendation\nRun `{}`.", r.primary_recommendation);
if !r.broken_anchors.is_empty() {
println!("\n### Broken design references");
for a in &r.broken_anchors {
println!("- `{}` ({}) — {}", a.anchor, a.category, a.reason);
}
}
}
fn list_change_items(cfg: &Config, want_parked: bool) -> Result<Vec<serde_json::Value>> {
let names = if want_parked {
change::list_parked(cfg)
} else {
change::list_active(cfg)
};
let mut items = Vec::new();
for name in &names {
let ch = change::load(cfg, name)?;
let (done, total) = task_counts(&ch.tasks_md());
let status = if total > 0 && done == total {
"done"
} else {
"in-progress"
};
let summary = first_line(&ch.proposal_md());
items.push(json!({
"name": name,
"status": status,
"completedTasks": done,
"totalTasks": total,
"summary": summary,
}));
}
Ok(items)
}
fn cmd_list(cfg: &Config, want_specs: bool, want_parked: bool, as_json: bool) -> Result<i32> {
if want_specs {
return cmd_list_specs(cfg, as_json);
}
let items = list_change_items(cfg, want_parked)?;
if as_json {
println!(
"{}",
serde_json::to_string_pretty(&json!({ "changes": items }))?
);
} else if items.is_empty() {
println!(
"{}",
if want_parked {
"No parked changes."
} else {
"No active changes."
}
);
} else {
for it in &items {
println!(
"{:<45} {}/{} {}",
it["name"].as_str().unwrap_or(""),
it["completedTasks"],
it["totalTasks"],
it["status"].as_str().unwrap_or("")
);
}
}
Ok(0)
}
fn list_specs_items(cfg: &Config) -> Result<Vec<serde_json::Value>> {
let names = spec::list(cfg)?;
let mut items = Vec::new();
for name in &names {
let summary = first_line(&cfg.specs_dir().join(name).join("spec.md"));
items.push(json!({ "name": name, "summary": summary }));
}
Ok(items)
}
fn cmd_list_specs(cfg: &Config, as_json: bool) -> Result<i32> {
let items = list_specs_items(cfg)?;
if as_json {
println!(
"{}",
serde_json::to_string_pretty(&json!({ "specs": items }))?
);
} else if items.is_empty() {
println!("No specs.");
} else {
for it in &items {
println!(
"{:<45} {}",
it["name"].as_str().unwrap_or(""),
it["summary"].as_str().unwrap_or("")
);
}
}
Ok(0)
}
#[derive(Debug)]
enum ShowContent {
Proposal(String),
Spec(String),
}
fn resolve_show_content(cfg: &Config, item: &str) -> Result<ShowContent> {
if let Some(ch) = change::try_load(cfg, item)? {
return Ok(ShowContent::Proposal(read_show_content(&ch.proposal_md())?));
}
if let Some(sp) = spec::try_load(cfg, item)? {
return Ok(ShowContent::Spec(read_show_content(&sp.spec_md())?));
}
anyhow::bail!("'{item}' is not a known change or spec")
}
fn read_show_content(path: &Path) -> Result<String> {
match std::fs::read_to_string(path) {
Ok(s) => Ok(s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
}
}
fn show_json(item: &str, content: &ShowContent) -> serde_json::Value {
match content {
ShowContent::Proposal(text) => json!({ "name": item, "proposal": text }),
ShowContent::Spec(text) => json!({ "name": item, "spec": text }),
}
}
fn cmd_show(cfg: &Config, item: &str, as_json: bool) -> Result<i32> {
let content = resolve_show_content(cfg, item)?;
if as_json {
println!(
"{}",
serde_json::to_string_pretty(&show_json(item, &content))?
);
} else {
let (ShowContent::Proposal(text) | ShowContent::Spec(text)) = &content;
print!("{text}");
}
Ok(0)
}
fn park_status_json(name: &str, parked: bool) -> serde_json::Value {
json!({ "name": name, "parked": parked })
}
fn cmd_park(cfg: &Config, name: &str, as_json: bool) -> Result<i32> {
change::park(cfg, name)?;
if as_json {
println!(
"{}",
serde_json::to_string_pretty(&park_status_json(name, true))?
);
} else {
println!("Parked '{name}'.");
}
Ok(0)
}
fn cmd_unpark(cfg: &Config, name: &str, as_json: bool) -> Result<i32> {
change::unpark(cfg, name)?;
if as_json {
println!(
"{}",
serde_json::to_string_pretty(&park_status_json(name, false))?
);
} else {
println!("Unparked '{name}'.");
}
Ok(0)
}
fn cmd_status(
cfg: &Config,
change_name: Option<&str>,
schema_name: Option<&str>,
as_json: bool,
) -> Result<i32> {
let report = schema::status(cfg, change_name, schema_name)?;
if as_json {
println!("{}", serde_json::to_string_pretty(&report)?);
return Ok(0);
}
println!("Change: {}", report.change_name);
println!("Schema: {}", report.schema_name);
println!();
for artifact in &report.artifacts {
let marker = match artifact.status {
schema::ArtifactState::Done => "✓",
schema::ArtifactState::Ready => "â—‹",
schema::ArtifactState::Blocked => "✗",
};
println!(" {marker} {} ({})", artifact.id, artifact.output_path);
if let Some(missing_deps) = &artifact.missing_deps {
println!(" blocked by: {}", missing_deps.join(", "));
}
}
println!();
if report.is_complete {
println!(" ✓ All artifacts complete");
}
Ok(0)
}
fn artifact_instructions_human(report: &instructions::ArtifactInstructions) -> String {
let mut output = format!(
"Artifact: {}\nOutput: {}\nDescription: {}\n\nInstruction:\n{}\n\n",
report.artifact_id, report.output_path, report.description, report.instruction
);
if !report.dependencies.is_empty() {
output.push_str("Dependencies:\n");
for dependency in &report.dependencies {
let glyph = if dependency.done { "✓" } else { "○" };
output.push_str(&format!(
" {glyph} {} ({})\n",
dependency.id, dependency.path
));
}
output.push('\n');
}
if !report.unlocks.is_empty() {
output.push_str("Unlocks:\n");
for artifact_id in &report.unlocks {
output.push_str(&format!(" - {artifact_id}\n"));
}
output.push('\n');
}
output.push_str("Template:\n");
output.push_str(report.template);
output.push('\n');
output
}
fn apply_instructions_human(report: &instructions::ApplyInstructions) -> String {
let mut output = format!(
"Change: {}\nSchema: {}\nState: {}\nProgress: {}/{} complete\n\n",
report.change_name,
report.schema_name,
match report.state {
instructions::ApplyState::Blocked => "blocked",
instructions::ApplyState::AllDone => "all_done",
instructions::ApplyState::Ready => "ready",
},
report.progress.complete,
report.progress.total
);
if !report.missing_artifacts.is_empty() {
output.push_str("Missing artifacts:\n");
for artifact in &report.missing_artifacts {
output.push_str(&format!(" - {artifact}\n"));
}
output.push('\n');
} else if !report.tasks.is_empty() {
output.push_str("Tasks:\n");
for task in &report.tasks {
let glyph = if task.done { "✓" } else { "○" };
output.push_str(&format!(" {glyph} {}\n", task.description));
}
output.push('\n');
}
output.push_str("Instruction:\n");
output.push_str(report.instruction);
output.push('\n');
output
}
fn cmd_instructions(
cfg: &Config,
artifact_id: Option<&str>,
change_name: Option<&str>,
schema_name: Option<&str>,
as_json: bool,
) -> Result<i32> {
let report = instructions::get(cfg, change_name, schema_name, artifact_id)?;
let output = if as_json {
format!("{}\n", serde_json::to_string_pretty(&report)?)
} else {
match &report {
instructions::InstructionOutput::Artifact(report) => {
artifact_instructions_human(report)
}
instructions::InstructionOutput::Apply(report) => apply_instructions_human(report),
}
};
let stdout = std::io::stdout();
let mut stdout = stdout.lock();
stdout
.write_all(output.as_bytes())
.context("writing instructions output")?;
Ok(0)
}
fn new_change_json(ch: &change::Change) -> serde_json::Value {
json!({
"name": ch.name,
"dir": ch.dir.to_string_lossy(),
"started_sha": ch.started_sha,
})
}
fn cmd_new_change(cfg: &Config, name: &str, as_json: bool) -> Result<i32> {
let ch = change::create(cfg, name)?;
if as_json {
println!("{}", serde_json::to_string_pretty(&new_change_json(&ch))?);
} else {
println!("Created change '{}' in {}.", ch.name, ch.dir.display());
if ch.started_sha.is_none() {
eprintln!(
"note: couldn't determine a git baseline for this change; \
task-blocked detection will be skipped for it."
);
}
}
Ok(0)
}
fn new_artifact_json(outcome: &artifact::NewArtifactOutcome) -> serde_json::Value {
json!({
"artifact": outcome.artifact,
"change": outcome.change,
"path": outcome.path.to_string_lossy(),
"status": "created",
"validated": outcome.validated,
"warnings": outcome.warnings,
})
}
fn cmd_new_artifact(
cfg: &Config,
type_name: &str,
capability: Option<&str>,
change_name: Option<&str>,
from_stdin: bool,
force: bool,
as_json: bool,
) -> Result<i32> {
let stdin_content = if from_stdin {
let mut content = String::new();
std::io::stdin()
.read_to_string(&mut content)
.context("reading stdin")?;
Some(content)
} else {
None
};
let outcome = artifact::create(
cfg,
type_name,
capability,
change_name,
stdin_content.as_deref(),
force,
)?;
if as_json {
println!("{}", serde_json::to_string(&new_artifact_json(&outcome))?);
} else {
println!("✓ Created {}: {}", outcome.artifact, outcome.path.display());
if outcome.validated {
println!(" Content validated ✓");
}
}
Ok(0)
}
fn task_done_json(outcome: &change::TaskDoneOutcome) -> serde_json::Value {
json!({
"change": outcome.change,
"status": "done",
"task_desc": outcome.task_desc,
"task_id": outcome.task_id.to_string(),
})
}
fn parse_task_id(raw: &str) -> Result<usize> {
raw.parse()
.map_err(|_| anyhow::anyhow!("Invalid task ID '{raw}': must be a number"))
}
fn cmd_task_done(
cfg: &Config,
change_name: Option<&str>,
task_id_raw: &str,
as_json: bool,
) -> Result<i32> {
let task_id = parse_task_id(task_id_raw)?;
let name = change::resolve(cfg, change_name)?;
let outcome = change::mark_task_done(cfg, &name, task_id)?;
if as_json {
println!(
"{}",
serde_json::to_string_pretty(&task_done_json(&outcome))?
);
} else {
println!(
"Task {} marked as done: {}",
outcome.task_id, outcome.task_desc
);
}
Ok(0)
}
fn cmd_archive(
cfg: &Config,
change_name: Option<&str>,
skip_specs: bool,
mark_tasks_complete: bool,
) -> Result<i32> {
let name = change::resolve(cfg, change_name)?;
let outcome = spectra_core::archive::archive(cfg, &name, skip_specs, mark_tasks_complete)?;
println!(
"Archived '{}' as '{}'.",
outcome.name, outcome.archived_name
);
for applied in &outcome.specs_applied {
println!(
"Specs applied: {} (added: {}, modified: {}, removed: {}, renamed: {})",
applied.capability, applied.added, applied.modified, applied.removed, applied.renamed
);
}
Ok(0)
}
fn task_counts(tasks_md: &Path) -> (usize, usize) {
let Ok(text) = std::fs::read_to_string(tasks_md) else {
return (0, 0);
};
let tasks = spectra_core::tasks::parse(&text);
(tasks.iter().filter(|t| t.done).count(), tasks.len())
}
fn first_line(path: &Path) -> String {
match std::fs::read_to_string(path) {
Ok(s) => s
.lines()
.find(|l| !l.trim().is_empty())
.map(str::to_string)
.unwrap_or_default(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => {
eprintln!("warning: reading {}: {e}", path.display());
String::new()
}
}
}
fn run() -> Result<i32> {
let cli = Cli::parse();
let use_color = color_enabled(cli.no_color);
let cwd = std::env::current_dir().context("getting current directory")?;
let root = find_root(&cwd);
match &cli.command {
Command::Init { adopt, json } => cmd_init(&root, *adopt, *json),
Command::Drift { change, json } => {
let cfg = require_initialized(&root)?;
cmd_drift(&cfg, change.as_deref(), *json, use_color)
}
Command::Analyze { change, json } => {
let cfg = require_initialized(&root)?;
cmd_analyze(&cfg, change.as_deref(), *json)
}
Command::Status {
change,
schema,
json,
} => {
let cfg = require_initialized(&root)?;
cmd_status(&cfg, change.as_deref(), schema.as_deref(), *json)
}
Command::Instructions {
artifact,
change,
schema,
json,
skill,
} => {
if let Some(skill) = skill {
anyhow::bail!("Unknown skill: {skill}");
}
let cfg = require_initialized(&root)?;
cmd_instructions(
&cfg,
artifact.as_deref(),
change.as_deref(),
schema.as_deref(),
*json,
)
}
Command::Validate {
change,
changes,
strict,
json,
} => {
let cfg = require_initialized(&root)?;
cmd_validate(&cfg, change.as_deref(), *changes, *strict, *json)
}
Command::List {
changes: _,
specs,
parked,
json,
} => {
let cfg = require_initialized(&root)?;
cmd_list(&cfg, *specs, *parked, *json)
}
Command::Show { item, json } => {
let cfg = require_initialized(&root)?;
cmd_show(&cfg, item, *json)
}
Command::Park { change, json } => {
let cfg = require_initialized(&root)?;
cmd_park(&cfg, change, *json)
}
Command::Unpark { change, json } => {
let cfg = require_initialized(&root)?;
cmd_unpark(&cfg, change, *json)
}
Command::New { target } => match target {
NewTarget::Change { name, json } => {
let cfg = require_initialized(&root)?;
cmd_new_change(&cfg, name, *json)
}
NewTarget::Artifact {
type_name,
capability,
change,
stdin,
force,
json,
} => {
let cfg = require_initialized(&root)?;
cmd_new_artifact(
&cfg,
type_name,
capability.as_deref(),
change.as_deref(),
*stdin,
*force,
*json,
)
}
},
Command::Task { target } => match target {
TaskTarget::Done {
task_id,
change,
json,
} => {
let cfg = require_initialized(&root)?;
cmd_task_done(&cfg, change.as_deref(), task_id, *json)
}
},
Command::Archive {
change,
skip_specs,
mark_tasks_complete,
} => {
let cfg = require_initialized(&root)?;
cmd_archive(&cfg, change.as_deref(), *skip_specs, *mark_tasks_complete)
}
}
}
fn main() -> ExitCode {
match run() {
Ok(code) => ExitCode::from(code as u8),
Err(e) => {
eprintln!("Error: {e:#}");
ExitCode::from(1)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn color_enabled_from_only_true_when_nothing_disables_it() {
assert!(color_enabled_from(false, false, true));
}
#[test]
fn color_enabled_from_no_color_flag_wins_even_with_tty_and_no_env() {
assert!(!color_enabled_from(true, false, true));
}
#[test]
fn color_enabled_from_no_color_env_wins_even_without_flag() {
assert!(!color_enabled_from(false, true, true));
}
#[test]
fn color_enabled_from_false_when_not_a_tty_even_with_nothing_else_set() {
assert!(!color_enabled_from(false, false, false));
}
#[test]
fn colorize_wraps_text_in_sgr_codes_only_when_enabled() {
assert_eq!(colorize("hi", "31", true), "\x1b[31mhi\x1b[0m");
assert_eq!(colorize("hi", "31", false), "hi");
}
#[test]
fn severity_sgr_code_maps_known_and_unknown_severities() {
assert_eq!(severity_sgr_code("light"), "32");
assert_eq!(severity_sgr_code("medium"), "33");
assert_eq!(severity_sgr_code("heavy"), "31");
assert_eq!(severity_sgr_code("anything-else"), "31");
}
#[test]
fn conclusion_line_colors_by_severity_when_enabled() {
assert_eq!(
conclusion_line("light", true),
"\x1b[32mDrift is minor — you can start work directly.\x1b[0m"
);
assert!(conclusion_line("light", false).starts_with("Drift is minor"));
assert!(!conclusion_line("light", false).contains('\x1b'));
}
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"spectra-cli-test-{}-{}-{seq}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
Self(dir)
}
}
impl std::ops::Deref for TempDir {
type Target = Path;
fn deref(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn list_changes_flag_conflicts_with_specs() {
let err = Cli::try_parse_from(["spectra", "list", "--changes", "--specs"]).unwrap_err();
assert_eq!(
err.kind(),
clap::error::ErrorKind::ArgumentConflict,
"expected --changes/--specs to be rejected as conflicting, got: {err}"
);
}
#[test]
fn list_changes_flag_conflicts_with_parked() {
let err = Cli::try_parse_from(["spectra", "list", "--changes", "--parked"]).unwrap_err();
assert_eq!(
err.kind(),
clap::error::ErrorKind::ArgumentConflict,
"expected --changes/--parked to be rejected as conflicting, got: {err}"
);
}
#[test]
fn list_changes_flag_parses_alone() {
let cli = Cli::try_parse_from(["spectra", "list", "--changes"]).unwrap();
match cli.command {
Command::List {
changes,
specs,
parked,
json,
} => {
assert!(changes);
assert!(!specs);
assert!(!parked);
assert!(!json);
}
_ => panic!("expected Command::List"),
}
}
#[test]
fn init_json_shape_matches_the_documented_contract() {
let outcome = spectra_core::init::InitOutcome {
root: PathBuf::from("/tmp/proj"),
spec_dir: "openspec".to_string(),
adopted: true,
gitignore_updated: true,
};
let value = init_json(&outcome);
assert_eq!(value["root"], "/tmp/proj");
assert_eq!(value["spec_dir"], "openspec");
assert_eq!(value["adopted"], true);
assert_eq!(value["gitignore_updated"], true);
}
#[test]
fn list_specs_items_shape_matches_specs_key_contract() {
let tmp = TempDir::new();
let cfg = Config {
root: tmp.to_path_buf(),
spec_dir: "openspec".to_string(),
locale: None,
};
let auth_dir = cfg.specs_dir().join("auth");
std::fs::create_dir_all(&auth_dir).unwrap();
std::fs::write(auth_dir.join("spec.md"), "# Auth\nHandles login.\n").unwrap();
let items = list_specs_items(&cfg).unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0]["name"].as_str(), Some("auth"));
assert_eq!(items[0]["summary"].as_str(), Some("# Auth"));
let wrapped = json!({ "specs": items });
assert_eq!(wrapped["specs"][0]["name"], "auth");
}
#[test]
fn list_specs_items_is_empty_when_no_specs_exist() {
let tmp = TempDir::new();
let cfg = Config {
root: tmp.to_path_buf(),
spec_dir: "openspec".to_string(),
locale: None,
};
let items = list_specs_items(&cfg).unwrap();
assert!(items.is_empty());
}
#[test]
fn list_change_items_parked_flag_selects_parked_changes() {
let tmp = TempDir::new();
let cfg = Config {
root: tmp.to_path_buf(),
spec_dir: "openspec".to_string(),
locale: None,
};
std::fs::create_dir_all(cfg.changes_dir().join("shipped")).unwrap();
std::fs::write(
cfg.changes_dir().join("shipped").join("proposal.md"),
"# Shipped\n",
)
.unwrap();
std::fs::create_dir_all(cfg.changes_dir().join("on-hold")).unwrap();
std::fs::write(
cfg.changes_dir().join("on-hold").join("proposal.md"),
"# On hold\n",
)
.unwrap();
std::fs::create_dir_all(tmp.join(".spectra").join("changes")).unwrap();
std::fs::write(
tmp.join(".spectra").join("changes").join("on-hold.parked"),
"",
)
.unwrap();
let active = list_change_items(&cfg, false).unwrap();
assert_eq!(active.len(), 1);
assert_eq!(active[0]["name"].as_str(), Some("shipped"));
let parked = list_change_items(&cfg, true).unwrap();
assert_eq!(parked.len(), 1);
assert_eq!(parked[0]["name"].as_str(), Some("on-hold"));
}
#[test]
fn list_change_items_parked_is_empty_when_none_parked() {
let tmp = TempDir::new();
let cfg = Config {
root: tmp.to_path_buf(),
spec_dir: "openspec".to_string(),
locale: None,
};
std::fs::create_dir_all(cfg.changes_dir().join("shipped")).unwrap();
std::fs::write(
cfg.changes_dir().join("shipped").join("proposal.md"),
"# Shipped\n",
)
.unwrap();
assert!(list_change_items(&cfg, true).unwrap().is_empty());
}
#[test]
fn resolve_show_content_prefers_change_over_spec() {
let tmp = TempDir::new();
let cfg = Config {
root: tmp.to_path_buf(),
spec_dir: "openspec".to_string(),
locale: None,
};
std::fs::create_dir_all(cfg.changes_dir().join("auth")).unwrap();
std::fs::write(
cfg.changes_dir().join("auth").join("proposal.md"),
"# Auth change\n",
)
.unwrap();
std::fs::create_dir_all(cfg.specs_dir().join("auth")).unwrap();
std::fs::write(
cfg.specs_dir().join("auth").join("spec.md"),
"# Auth spec\n",
)
.unwrap();
match resolve_show_content(&cfg, "auth").unwrap() {
ShowContent::Proposal(text) => assert_eq!(text, "# Auth change\n"),
ShowContent::Spec(_) => panic!("expected the change to take priority over the spec"),
}
}
#[test]
fn resolve_show_content_falls_back_to_spec() {
let tmp = TempDir::new();
let cfg = Config {
root: tmp.to_path_buf(),
spec_dir: "openspec".to_string(),
locale: None,
};
std::fs::create_dir_all(cfg.specs_dir().join("billing")).unwrap();
std::fs::write(
cfg.specs_dir().join("billing").join("spec.md"),
"# Billing spec\n",
)
.unwrap();
match resolve_show_content(&cfg, "billing").unwrap() {
ShowContent::Spec(text) => assert_eq!(text, "# Billing spec\n"),
ShowContent::Proposal(_) => panic!("expected a spec, not a change"),
}
}
#[test]
fn resolve_show_content_errors_when_neither_change_nor_spec() {
let tmp = TempDir::new();
let cfg = Config {
root: tmp.to_path_buf(),
spec_dir: "openspec".to_string(),
locale: None,
};
assert!(resolve_show_content(&cfg, "ghost").is_err());
}
#[test]
fn resolve_show_content_propagates_real_errors_instead_of_falling_back_to_spec() {
let tmp = TempDir::new();
let cfg = Config {
root: tmp.to_path_buf(),
spec_dir: "openspec".to_string(),
locale: None,
};
std::fs::create_dir_all(cfg.changes_dir().join("broken")).unwrap();
std::fs::create_dir_all(cfg.changes_dir().join("broken").join(".openspec.yaml")).unwrap();
std::fs::create_dir_all(cfg.specs_dir().join("broken")).unwrap();
std::fs::write(
cfg.specs_dir().join("broken").join("spec.md"),
"# Should not be used\n",
)
.unwrap();
let err = resolve_show_content(&cfg, "broken").unwrap_err();
assert!(!err.to_string().contains("is not a known change or spec"));
}
#[test]
fn show_json_uses_proposal_key_for_change_content() {
let value = show_json("my-change", &ShowContent::Proposal("hello".to_string()));
assert_eq!(value["name"], "my-change");
assert_eq!(value["proposal"], "hello");
assert!(value.get("spec").is_none());
}
#[test]
fn show_json_uses_spec_key_for_spec_content() {
let value = show_json("auth", &ShowContent::Spec("# Auth\n".to_string()));
assert_eq!(value["name"], "auth");
assert_eq!(value["spec"], "# Auth\n");
assert!(value.get("proposal").is_none());
}
#[test]
fn park_status_json_reflects_parked_true_and_false() {
assert_eq!(park_status_json("my-change", true)["parked"], true);
assert_eq!(park_status_json("my-change", false)["parked"], false);
assert_eq!(park_status_json("my-change", true)["name"], "my-change");
}
fn sample_change(dir: PathBuf, started_sha: Option<&str>) -> change::Change {
change::Change {
name: "my-change".to_string(),
dir,
metadata: Default::default(),
started_sha: started_sha.map(str::to_string),
parked: false,
}
}
#[test]
fn new_change_json_shape_matches_the_documented_contract() {
let ch = sample_change(PathBuf::from("/tmp/changes/my-change"), Some("abc123"));
let value = new_change_json(&ch);
assert_eq!(value["name"], "my-change");
assert_eq!(value["dir"], "/tmp/changes/my-change");
assert_eq!(value["started_sha"], "abc123");
}
#[test]
fn new_change_json_started_sha_is_null_when_absent() {
let ch = sample_change(PathBuf::from("/tmp/changes/my-change"), None);
assert!(new_change_json(&ch)["started_sha"].is_null());
}
#[cfg(unix)]
#[test]
fn new_change_json_does_not_panic_on_a_non_utf8_path() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let dir = PathBuf::from(OsStr::from_bytes(b"/tmp/bad-\xFF-path"));
let ch = sample_change(dir, None);
let value = new_change_json(&ch);
assert!(value["dir"].as_str().unwrap().contains("bad-"));
}
#[test]
fn task_done_json_shape_matches_the_documented_contract() {
let outcome = change::TaskDoneOutcome {
change: "my-change".to_string(),
task_id: 3,
task_desc: "do the thing".to_string(),
};
let value = task_done_json(&outcome);
assert_eq!(value["change"], "my-change");
assert_eq!(value["status"], "done");
assert_eq!(value["task_desc"], "do the thing");
assert_eq!(value["task_id"], "3");
}
#[test]
fn task_done_json_serializes_keys_in_alphabetical_order() {
let outcome = change::TaskDoneOutcome {
change: "my-change".to_string(),
task_id: 3,
task_desc: "do the thing".to_string(),
};
let serialized = serde_json::to_string(&task_done_json(&outcome)).unwrap();
assert_eq!(
serialized,
r#"{"change":"my-change","status":"done","task_desc":"do the thing","task_id":"3"}"#
);
}
#[test]
fn parse_task_id_accepts_a_valid_number() {
assert_eq!(parse_task_id("3").unwrap(), 3);
}
#[test]
fn parse_task_id_rejects_non_numeric_input() {
let err = parse_task_id("abc").unwrap_err();
assert_eq!(err.to_string(), "Invalid task ID 'abc': must be a number");
}
#[test]
fn parse_task_id_rejects_negative_numbers() {
let err = parse_task_id("-1").unwrap_err();
assert_eq!(err.to_string(), "Invalid task ID '-1': must be a number");
}
}