use std::collections::HashMap;
use std::io::Read;
use anyhow::{Context, Result};
use camino::Utf8PathBuf;
use cordance_core::lock::SourceLock;
use cordance_core::pack::{CordancePack, DoctrinePin, PackTargets, ProjectIdentity};
use sha2::{Digest, Sha256};
pub struct PackConfig {
pub target: Utf8PathBuf,
pub output_mode: OutputMode,
pub selected_targets: PackTargets,
pub doctrine_root: Option<Utf8PathBuf>,
pub llm_provider: Option<String>,
pub ollama_model: Option<String>,
pub quiet: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum OutputMode {
Write,
DryRun,
Diff,
}
impl OutputMode {
pub fn from_str(s: &str) -> Self {
match s {
"dry-run" => Self::DryRun,
"diff" => Self::Diff,
_ => Self::Write,
}
}
}
#[allow(clippy::too_many_lines)]
pub fn run(config: &PackConfig) -> Result<CordancePack> {
let cfg = crate::config::Config::load_strict(&config.target)
.with_context(|| format!("loading cordance.toml at {}", config.target))?;
let name = detect_project_name(&config.target);
let kind = detect_project_kind(&config.target);
let host_os = std::env::consts::OS.to_string();
let axiom_pin = Some(cfg.axiom_version(&config.target));
let (sources, blocked_count, scan_error) = match cordance_scan::scan_repo(&config.target) {
Ok(all) => {
let total = all.len();
let unblocked: Vec<_> = all.into_iter().filter(|r| !r.blocked).collect();
let blocked_n = total - unblocked.len();
if blocked_n > 0 {
tracing::debug!(
blocked = blocked_n,
total,
"cordance pack: dropped blocked entries from pack.sources"
);
}
(unblocked, blocked_n, None)
}
Err(e) => {
tracing::warn!(error = %e, "cordance pack: source scan failed");
(vec![], 0, Some(e.to_string()))
}
};
let _ = blocked_count;
let doctrine_source_str = config
.doctrine_root
.as_ref()
.map_or_else(|| cfg.doctrine.source.clone(), |p| p.as_str().to_string());
let doctrine_root = config
.doctrine_root
.clone()
.unwrap_or_else(|| cfg.doctrine_root(&config.target));
let pin_commit: Option<&str> = if cfg.doctrine.pin_commit == "auto" {
None
} else {
Some(cfg.doctrine.pin_commit.as_str())
};
let mut doctrine_load_warning: Option<String> = None;
let doctrine_cache_dir =
cordance_core::paths::doctrine_cache_dir_for_url(&cfg.doctrine.fallback_repo);
let doctrine_idx = match cordance_doctrine::load_doctrine_with_fallback(
&doctrine_root,
&cfg.doctrine.fallback_repo,
Some(&doctrine_cache_dir),
pin_commit,
) {
Ok(idx) => idx,
Err(err) => {
tracing::warn!(error = %err, "doctrine load failed; using empty index");
doctrine_load_warning = Some(format!("doctrine load failed: {err}"));
cordance_doctrine::DoctrineIndex::empty(doctrine_root.clone())
}
};
let doctrine_pins: Vec<DoctrinePin> = if let Some(commit) = &doctrine_idx.commit {
vec![DoctrinePin {
repo: doctrine_idx.repo.clone(),
commit: commit.clone(),
source_path: Utf8PathBuf::from(
format!("{doctrine_source_str}/doctrine/SEMANTIC_INDEX.md")
.replace('\\', "/"),
),
}]
} else {
vec![]
};
let mut pack = CordancePack {
schema: cordance_core::schema::CORDANCE_PACK_V1.into(),
project: ProjectIdentity {
name,
repo_root: config.target.clone(),
kind,
host_os,
axiom_pin,
},
sources,
doctrine_pins,
targets: config.selected_targets.clone(),
outputs: vec![],
source_lock: SourceLock::empty(),
advise: cordance_core::advise::AdviseReport::empty(),
residual_risk: vec!["claim_ceiling=candidate".into()],
};
if let Some(err_msg) = scan_error {
pack.residual_risk
.push(format!("scan error: {err_msg}"));
}
if let Some(warning) = doctrine_load_warning {
pack.residual_risk.push(warning);
}
maybe_run_llm(config, &cfg, &mut pack);
let emitters = build_emitters(&config.selected_targets);
let all_outputs = dispatch_emitters(&emitters, config, &pack)?;
pack.outputs = all_outputs;
pack.source_lock = SourceLock::compute_from_pack(&pack);
write_sources_lock(config, &pack)?;
if let Ok(report) = cordance_advise::run_all(&pack) {
pack.advise = report;
}
let post_emit_emitters: Vec<Box<dyn cordance_emit::TargetEmitter>> = vec![
Box::new(cordance_emit::pack_json::PackJsonEmitter),
Box::new(cordance_emit::evidence_map::EvidenceMapEmitter),
];
let _post_outputs = dispatch_emitters(&post_emit_emitters, config, &pack)?;
Ok(pack)
}
fn write_sources_lock(config: &PackConfig, pack: &CordancePack) -> Result<()> {
if config.output_mode != OutputMode::Write {
return Ok(());
}
let lock_path = config.target.join(".cordance").join("sources.lock");
let lock_json = serde_json::to_string_pretty(&pack.source_lock)
.context("serialising sources.lock")?;
cordance_core::fs::safe_write_with_mkdir(lock_path.as_std_path(), lock_json.as_bytes())
.context("writing sources.lock")?;
Ok(())
}
fn build_emitters(
targets: &PackTargets,
) -> Vec<Box<dyn cordance_emit::TargetEmitter>> {
let mut v: Vec<Box<dyn cordance_emit::TargetEmitter>> = vec![];
if targets.claude_code {
v.push(Box::new(cordance_emit::agents_md::AgentsMdEmitter));
v.push(Box::new(cordance_emit::claude_md::ClaudeMdEmitter));
}
if targets.cursor {
v.push(Box::new(cordance_emit::cursor::CursorEmitter));
}
if targets.codex {
v.push(Box::new(cordance_emit::codex::CodexEmitter));
}
if targets.axiom_harness_target {
v.push(Box::new(
cordance_emit::harness_target::HarnessTargetEmitter,
));
}
let _ = targets.cortex_receipt;
v
}
fn dispatch_emitters(
emitters: &[Box<dyn cordance_emit::TargetEmitter>],
config: &PackConfig,
pack: &CordancePack,
) -> Result<Vec<cordance_core::pack::PackOutput>> {
let mut all_outputs = vec![];
match config.output_mode {
OutputMode::Write => {
for emitter in emitters {
let outputs = emitter
.emit(pack, &config.target)
.with_context(|| format!("emitter '{}' failed", emitter.name()))?;
all_outputs.extend(outputs);
}
}
OutputMode::DryRun => {
for emitter in emitters {
let outputs = emitter
.plan(pack, &config.target)
.with_context(|| format!("emitter '{}' plan failed", emitter.name()))?;
if !config.quiet {
for o in &outputs {
println!("would write: {} ({} bytes)", o.path, o.bytes);
}
}
all_outputs.extend(outputs);
}
}
OutputMode::Diff => {
for emitter in emitters {
let outputs = emitter
.plan(pack, &config.target)
.with_context(|| format!("emitter '{}' diff-plan failed", emitter.name()))?;
for o in &outputs {
let abs = config.target.join(&o.path);
let line = if abs.exists() {
let on_disk = std::fs::read_to_string(&abs).unwrap_or_default();
let on_disk_sha = hex::encode(Sha256::digest(on_disk.as_bytes()));
if on_disk_sha == o.sha256 {
format!("unchanged: {}", o.path)
} else {
format!("changed: {}", o.path)
}
} else {
format!("new: {}", o.path)
};
if !config.quiet {
println!("{line}");
}
all_outputs.push(o.clone());
}
}
}
}
Ok(all_outputs)
}
fn detect_project_name(root: &Utf8PathBuf) -> String {
if let Some(name) = read_cargo_package_name(root) {
return name;
}
if let Ok(canonical) = std::fs::canonicalize(root.as_std_path()) {
if let Some(dir_name) = canonical.file_name() {
if let Some(s) = dir_name.to_str() {
return s.to_string();
}
}
}
root.file_name().unwrap_or("unknown").to_string()
}
fn read_cargo_package_name(root: &Utf8PathBuf) -> Option<String> {
#[derive(serde::Deserialize, Default)]
struct PkgSection {
name: Option<String>,
}
#[derive(serde::Deserialize, Default)]
struct WorkspaceSection {
package: Option<PkgSection>,
}
#[derive(serde::Deserialize, Default)]
struct CargoToml {
package: Option<PkgSection>,
workspace: Option<WorkspaceSection>,
}
let cargo = root.join("Cargo.toml");
if !cargo.exists() {
return None;
}
let content = std::fs::read_to_string(&cargo).ok()?;
let parsed: CargoToml = toml::from_str(&content).ok()?;
if let Some(pkg) = parsed.package {
if let Some(name) = pkg.name {
if !name.is_empty() {
return Some(name);
}
}
}
if let Some(ws) = parsed.workspace {
if let Some(pkg) = ws.package {
if let Some(name) = pkg.name {
if !name.is_empty() {
return Some(name);
}
}
}
}
None
}
fn detect_project_kind(root: &Utf8PathBuf) -> String {
if root.join("Cargo.toml").exists() {
return "rust-workspace".into();
}
if root.join("package.json").exists() {
return "typescript-node".into();
}
if root.join("pyproject.toml").exists() || root.join("setup.py").exists() {
return "python".into();
}
if root.join("go.mod").exists() {
return "go".into();
}
"unknown".into()
}
fn resolve_llm_provider(config: &PackConfig, cfg: &crate::config::Config) -> Option<String> {
config
.llm_provider
.clone()
.or_else(|| Some(cfg.llm.provider.clone()))
.filter(|p| p != "none")
}
fn maybe_run_llm(config: &PackConfig, cfg: &crate::config::Config, pack: &mut CordancePack) {
let Some(provider) = resolve_llm_provider(config, cfg) else {
return;
};
if provider != "ollama" {
pack.residual_risk
.push(format!("LLM provider '{provider}' is not supported; skipping enrichment"));
return;
}
let mut adapter = cordance_llm::OllamaAdapter::from_config(&cfg.llm.ollama);
if let Some(override_model) = &config.ollama_model {
override_model.clone_into(&mut adapter.model);
}
let base_url = adapter.base_url.clone();
let model = adapter.model.clone();
if !adapter.is_available() {
eprintln!(
"cordance: warning — Ollama not reachable at {base_url}, skipping LLM enrichment"
);
pack.residual_risk
.push(format!("LLM enrichment skipped: ollama/{model} at {base_url} unreachable"));
return;
}
let sources: Vec<_> = pack
.sources
.iter()
.filter(|s| !s.blocked)
.take(20)
.cloned()
.collect();
let source_ids: Vec<String> = sources.iter().map(|s| s.id.clone()).collect();
let prompt = cordance_llm::prompt::bounded_pack_summary_prompt(
&sources,
&[],
"Summarise this project's main purpose in one paragraph as a candidate observation.",
);
let source_content_map = load_source_content_map(pack, &source_ids);
match adapter.generate_with_grounding(&prompt, &source_ids, &source_content_map) {
Ok(candidate) => {
let claim_count = candidate.claims.len();
pack.residual_risk.push(format!(
"LLM candidate prose attached: {claim_count} claim(s) from ollama/{model}"
));
let candidate_path = config.target.join(".cordance").join("llm-candidate.json");
match serde_json::to_string_pretty(&candidate) {
Ok(json) => {
if let Err(e) = cordance_core::fs::safe_write_with_mkdir(
candidate_path.as_std_path(),
json.as_bytes(),
) {
eprintln!(
"cordance: warning — failed to write {candidate_path}: {e}"
);
}
}
Err(e) => {
eprintln!(
"cordance: warning — failed to serialise LLM candidate: {e}"
);
}
}
}
Err(e) => {
eprintln!("cordance: warning — Ollama generation failed: {e}");
pack.residual_risk
.push(format!("LLM enrichment failed: {e}"));
}
}
}
fn load_source_content_map(
pack: &CordancePack,
cited_ids: &[String],
) -> HashMap<String, String> {
const MAX_PER_SOURCE: u64 = 4 * 1024;
let mut map: HashMap<String, String> = HashMap::with_capacity(cited_ids.len());
for id in cited_ids {
let Some(record) = pack.sources.iter().find(|s| &s.id == id) else {
continue;
};
if record.blocked {
continue;
}
let abs = pack.project.repo_root.join(&record.path);
let Ok(file) = std::fs::File::open(abs.as_std_path()) else {
continue;
};
let mut buf = String::new();
if file.take(MAX_PER_SOURCE).read_to_string(&mut buf).is_ok() {
map.insert(id.clone(), buf);
}
}
map
}
#[cfg(test)]
mod tests {
use super::*;
fn write_cargo(content: &str) -> (tempfile::TempDir, Utf8PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let target = Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
.expect("tempdir is utf8");
std::fs::write(target.join("Cargo.toml"), content).expect("write Cargo.toml");
(dir, target)
}
#[test]
fn detect_project_name_reads_package_section() {
let (_d, target) = write_cargo(
"[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\n",
);
assert_eq!(detect_project_name(&target), "my-crate");
}
#[test]
fn detect_project_name_handles_no_space_around_equals() {
let (_d, target) = write_cargo("[package]\nname=\"compact\"\n");
assert_eq!(detect_project_name(&target), "compact");
}
#[test]
fn detect_project_name_reads_workspace_package_section() {
let (_d, target) = write_cargo(
"[workspace]\nmembers = [\"foo\"]\n\n[workspace.package]\nname = \"ws-name\"\nversion = \"0.1.0\"\n",
);
assert_eq!(detect_project_name(&target), "ws-name");
}
#[test]
fn detect_project_name_prefers_package_over_workspace_package() {
let (_d, target) = write_cargo(
"[package]\nname = \"actual\"\n\n[workspace.package]\nname = \"inherited\"\n",
);
assert_eq!(detect_project_name(&target), "actual");
}
#[test]
fn detect_project_name_ignores_bin_name() {
let (_d, target) = write_cargo(
"[package]\nname = \"libname\"\n\n[[bin]]\nname = \"binname\"\npath = \"src/main.rs\"\n",
);
assert_eq!(detect_project_name(&target), "libname");
}
#[test]
fn detect_project_name_falls_back_when_cargo_toml_absent() {
let dir = tempfile::tempdir().expect("tempdir");
let target = Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
.expect("tempdir is utf8");
let detected = detect_project_name(&target);
let expected = target.file_name().unwrap_or("unknown").to_string();
assert!(!detected.is_empty());
assert!(
detected == expected
|| std::fs::canonicalize(target.as_std_path())
.ok()
.and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))
.is_some_and(|c| detected == c),
"detected={detected} expected={expected}"
);
}
#[test]
fn detect_project_name_falls_back_when_cargo_toml_malformed() {
let (_d, target) = write_cargo("this is not = = valid toml");
let detected = detect_project_name(&target);
assert!(!detected.is_empty(), "must produce some non-empty fallback");
}
#[test]
fn detect_project_name_falls_back_when_name_missing() {
let (_d, target) = write_cargo("[package]\nversion = \"0.1.0\"\n");
let detected = detect_project_name(&target);
assert!(!detected.is_empty());
assert_ne!(detected, "0.1.0");
}
#[test]
fn write_sources_lock_propagates_io_failure_instead_of_swallowing() {
let dir = tempfile::tempdir().expect("tempdir");
let target = Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
.expect("tempdir is utf8");
std::fs::write(target.join(".cordance"), b"blocker").expect("seed blocker file");
let config = PackConfig {
target: target.clone(),
output_mode: OutputMode::Write,
selected_targets: PackTargets::default(),
doctrine_root: None,
llm_provider: None,
ollama_model: None,
quiet: true,
};
let pack = CordancePack {
schema: "test".into(),
project: ProjectIdentity {
name: "test".into(),
kind: "unknown".into(),
repo_root: target,
host_os: "test".into(),
axiom_pin: None,
},
sources: vec![],
doctrine_pins: vec![],
targets: PackTargets::default(),
outputs: vec![],
source_lock: SourceLock::empty(),
advise: cordance_core::advise::AdviseReport::empty(),
residual_risk: vec![],
};
let result = write_sources_lock(&config, &pack);
assert!(
result.is_err(),
"expected write_sources_lock to surface the create_dir_all I/O failure, got Ok"
);
let err = result.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("writing sources.lock"),
"error chain must mention `writing sources.lock`; got: {msg}"
);
}
}