#![forbid(unsafe_code)]
mod advise_cmd;
mod check_cmd;
mod completions_cmd;
mod config;
mod cortex_cmd;
mod doctor_cmd;
mod doctrine_cmd;
mod explain_cmd;
mod init_cmd;
mod mcp;
mod pack_cmd;
mod scan_cmd;
mod serve_cmd;
mod watch_cmd;
use std::process::ExitCode;
use anyhow::Context;
use camino::Utf8PathBuf;
use clap::{CommandFactory, Parser, Subcommand};
use cordance_core::pack::PackTargets;
#[derive(Parser, Debug)]
#[command(
name = "cordance",
version,
about = "Deterministic-first context-pack compiler.",
long_about = "Cordance compiles project doctrine, ADRs, schemas, and source layout \
into auditable AI-agent context packs. Doctrine-shaped. Cortex-aware. \
Axiom-harness-compatible. No LLM at v0."
)]
struct Cli {
#[arg(long, env = "CORDANCE_CONFIG")]
config: Option<String>,
#[arg(long, default_value = ".")]
target: String,
#[arg(long, global = true, default_value_t = false)]
allow_outside_cwd: bool,
#[command(subcommand)]
cmd: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
Init,
Scan {
#[arg(long)]
json: bool,
},
Pack {
#[arg(long, default_value = "write")]
output_mode: String,
#[arg(long)]
targets: Option<String>,
#[arg(long)]
llm: Option<String>,
#[arg(long, default_value = "qwen2.5-coder:14b")]
ollama_model: String,
},
Advise {
#[arg(long)]
json: bool,
},
Doctrine {
topic: String,
},
#[command(subcommand)]
Cortex(CortexCmd),
Check,
Explain {
rule: String,
},
Watch {
#[arg(long, default_value = "500")]
debounce_ms: u64,
},
Serve,
Completions {
shell: String,
},
Doctor,
}
#[derive(Subcommand, Debug)]
enum CortexCmd {
Push {
#[arg(long)]
dry_run: bool,
},
}
#[must_use]
pub fn run() -> ExitCode {
let cli = Cli::parse();
if !matches!(cli.cmd, Command::Serve) {
let _ = tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_env("CORDANCE_LOG")
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.try_init();
}
match dispatch(&cli) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprint!("cordance: {e}");
for cause in e.chain().skip(1) {
eprint!("\n caused by: {cause}");
}
eprintln!();
ExitCode::from(1)
}
}
}
fn dispatch(cli: &Cli) -> anyhow::Result<()> {
let target = validate_cli_target(&cli.target, cli.allow_outside_cwd)?;
let cfg = config::Config::load_strict(&target)
.with_context(|| format!("loading cordance.toml at {target}"))?;
match &cli.cmd {
Command::Init => {
init_cmd::run(&target)?;
}
Command::Scan { json } => {
scan_cmd::run(&target, *json)?;
}
Command::Pack {
output_mode,
targets,
llm,
ollama_model,
} => {
let selected_targets = PackTargets::from_csv(targets.as_deref())
.with_context(|| "parsing --targets")?;
let pack_config = pack_cmd::PackConfig {
target,
output_mode: pack_cmd::OutputMode::from_str(output_mode),
selected_targets,
doctrine_root: None,
llm_provider: llm.clone(),
ollama_model: Some(ollama_model.clone()),
quiet: false,
};
let pack = pack_cmd::run(&pack_config)?;
let counts = pack.outputs.len();
println!("cordance pack: {counts} outputs written");
}
Command::Advise { json } => {
let pack_config = pack_cmd::PackConfig {
target,
output_mode: pack_cmd::OutputMode::DryRun,
selected_targets: PackTargets::default(),
doctrine_root: None,
llm_provider: None,
ollama_model: None,
quiet: false,
};
let pack = pack_cmd::run(&pack_config)?;
advise_cmd::run(&pack, *json)?;
}
Command::Doctrine { topic } => {
doctrine_cmd::run(topic, &cfg.doctrine_root(&target))?;
}
Command::Cortex(CortexCmd::Push { dry_run }) => {
let pack_config = pack_cmd::PackConfig {
target: target.clone(),
output_mode: pack_cmd::OutputMode::DryRun,
quiet: false,
selected_targets: PackTargets {
cortex_receipt: true,
..Default::default()
},
doctrine_root: None,
llm_provider: None,
ollama_model: None,
};
let pack = pack_cmd::run(&pack_config)?;
cortex_cmd::run_push(&pack, &target, *dry_run)?;
}
Command::Check => {
let code = check_cmd::run(&target)?;
std::process::exit(code);
}
Command::Watch { debounce_ms } => {
watch_cmd::run(&target, *debounce_ms)?;
}
Command::Serve => {
serve_cmd::run(&cfg, &target)?;
}
Command::Completions { shell } => {
completions_cmd::run(shell, Cli::command())?;
}
Command::Explain { rule } => {
explain_cmd::run(rule, &target)?;
}
Command::Doctor => {
doctor_cmd::run(&cfg, &target)?;
}
}
Ok(())
}
fn validate_cli_target(target: &str, allow_outside: bool) -> anyhow::Result<Utf8PathBuf> {
if target.is_empty() {
anyhow::bail!("target is empty");
}
if target.as_bytes().contains(&0) {
anyhow::bail!("target contains NUL byte");
}
if !allow_outside {
if target.starts_with("\\\\") || target.starts_with("//") {
anyhow::bail!(
"UNC and extended-length paths are not permitted without --allow-outside-cwd"
);
}
}
let raw = Utf8PathBuf::from(target);
let cwd = std::env::current_dir().context("cannot resolve current directory")?;
let cwd_canonical = dunce::canonicalize(&cwd).context("cannot canonicalise cwd")?;
let abs_path: std::path::PathBuf = if raw.is_absolute() {
raw.as_std_path().to_path_buf()
} else {
cwd.join(raw.as_std_path())
};
if !allow_outside {
reject_symlinks_pointing_outside(&abs_path, &cwd_canonical)?;
}
let canonical = if abs_path.exists() {
dunce::canonicalize(&abs_path)
.with_context(|| format!("cannot canonicalise {target}"))?
} else {
let mut probe = abs_path.clone();
while !probe.exists() {
match probe.parent() {
Some(p) => probe = p.to_path_buf(),
None => anyhow::bail!("target path has no existing ancestor: {target}"),
}
}
let canon_ancestor = dunce::canonicalize(&probe)
.with_context(|| format!("cannot canonicalise ancestor of {target}"))?;
let suffix = abs_path.strip_prefix(&probe).unwrap_or(&abs_path);
canon_ancestor.join(suffix)
};
if !allow_outside && !canonical.starts_with(&cwd_canonical) {
anyhow::bail!(
"target is outside the current working directory; pass --allow-outside-cwd \
to permit it explicitly"
);
}
Utf8PathBuf::try_from(canonical)
.map_err(|_| anyhow::anyhow!("target path is not valid UTF-8"))
}
fn reject_symlinks_pointing_outside(
path: &std::path::Path,
cwd_canonical: &std::path::Path,
) -> anyhow::Result<()> {
let mut probe: std::path::PathBuf = path.to_path_buf();
loop {
if is_reparse_or_symlink(&probe) {
match std::fs::read_link(&probe) {
Ok(link_target) => {
let resolved = if link_target.is_absolute() {
link_target.clone()
} else {
probe
.parent()
.map_or_else(|| link_target.clone(), |p| p.join(&link_target))
};
let resolved_canonical = if resolved.exists() {
dunce::canonicalize(&resolved).with_context(|| {
format!(
"cannot canonicalise symlink target {}",
resolved.display()
)
})?
} else {
normalise_path_segments(&resolved)
};
if !resolved_canonical.starts_with(cwd_canonical) {
anyhow::bail!(
"target path contains a symlink pointing outside cwd; \
pass --allow-outside-cwd to permit"
);
}
}
Err(_) => {
anyhow::bail!(
"target path contains a reparse point whose target cannot be \
resolved; pass --allow-outside-cwd to permit"
);
}
}
}
if !probe.pop() {
break;
}
}
Ok(())
}
fn normalise_path_segments(p: &std::path::Path) -> std::path::PathBuf {
let mut out = std::path::PathBuf::new();
for component in p.components() {
match component {
std::path::Component::ParentDir => {
out.pop();
}
std::path::Component::CurDir => {}
other => out.push(other),
}
}
out
}
fn is_reparse_or_symlink(path: &std::path::Path) -> bool {
let Ok(meta) = std::fs::symlink_metadata(path) else {
return false;
};
if meta.file_type().is_symlink() {
return true;
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
if meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn cli_parses() {
Cli::command().debug_assert();
}
#[test]
fn validate_cli_target_accepts_dot() {
let resolved = validate_cli_target(".", false).expect("cwd should be allowed");
let cwd = std::env::current_dir().expect("cwd");
let cwd_canon = dunce::canonicalize(&cwd).expect("canon cwd");
assert!(resolved.as_std_path().starts_with(&cwd_canon));
}
#[test]
fn validate_cli_target_rejects_empty() {
let err = validate_cli_target("", false).expect_err("empty must error");
assert!(err.to_string().contains("empty"));
}
#[test]
fn validate_cli_target_rejects_nul() {
let err = validate_cli_target("foo\0bar", false).expect_err("NUL must error");
assert!(err.to_string().contains("NUL"));
}
#[test]
fn validate_cli_target_rejects_unc_without_flag() {
let err = validate_cli_target("\\\\server\\share", false)
.expect_err("UNC must error without --allow-outside-cwd");
assert!(err.to_string().contains("UNC"));
}
#[test]
#[cfg(unix)]
fn cli_rejects_dangling_symlink_pointing_outside_cwd() {
let dir = tempfile::tempdir().expect("tempdir");
let link_path = dir.path().join("evil-link");
std::os::unix::fs::symlink("/etc/passwd-nonexistent", &link_path)
.expect("symlink");
let result = validate_cli_target(
link_path.to_str().expect("symlink path is utf8"),
false,
);
assert!(
result.is_err(),
"dangling symlink to /etc should be rejected, got {result:?}"
);
let msg = format!("{:?}", result.err().unwrap_or_else(|| anyhow::anyhow!("?")));
assert!(
msg.contains("symlink") || msg.contains("outside"),
"expected symlink/outside-cwd error, got {msg}"
);
}
#[test]
#[cfg(unix)]
fn cli_rejects_relative_symlink_escaping_cwd_via_dotdot() {
let dir = tempfile::tempdir().expect("tempdir");
let link_path = dir.path().join("trap");
std::os::unix::fs::symlink(
"../../../../../../../../../../../etc/passwd-nonexistent",
&link_path,
)
.expect("symlink");
let result = validate_cli_target(
link_path.to_str().expect("symlink path is utf8"),
false,
);
assert!(
result.is_err(),
"relative symlink with .. escape must be rejected, got {result:?}"
);
let msg = format!("{:?}", result.err().unwrap_or_else(|| anyhow::anyhow!("?")));
assert!(
msg.contains("symlink") || msg.contains("outside"),
"expected symlink/outside-cwd error, got {msg}"
);
}
#[test]
fn normalise_path_segments_pops_dotdot() {
let normalised = normalise_path_segments(std::path::Path::new(
"/some/base/../../etc/passwd",
));
assert_eq!(normalised, std::path::PathBuf::from("/etc/passwd"));
}
#[test]
fn normalise_path_segments_drops_curdir() {
let normalised = normalise_path_segments(std::path::Path::new(
"/some/./base/./file",
));
assert_eq!(normalised, std::path::PathBuf::from("/some/base/file"));
}
#[test]
fn normalise_path_segments_handles_relative() {
let normalised = normalise_path_segments(std::path::Path::new(
"foo/bar/../baz",
));
assert_eq!(normalised, std::path::PathBuf::from("foo/baz"));
}
}