use camino::Utf8PathBuf;
use cordance_core::pack::{CordancePack, PackTargets};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::mcp::error::{McpToolError, McpToolResult};
use crate::pack_cmd::{self, OutputMode, PackConfig};
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
pub struct PackDryRunParams {
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub targets: Option<String>,
}
#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct PackDryRunOutput {
pub schema: String,
pub project_name: String,
pub planned_outputs: Vec<PlannedOutput>,
pub source_count: usize,
pub doctrine_commit: Option<String>,
pub residual_risk: Vec<String>,
}
#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct PlannedOutput {
#[schemars(with = "String")]
pub path: Utf8PathBuf,
pub target: String,
pub sha256: String,
pub bytes: u64,
pub managed: bool,
pub source_anchors: Vec<String>,
}
pub fn dry_run(
target: &Utf8PathBuf,
cfg: &Config,
targets: Option<&str>,
) -> McpToolResult<PackDryRunOutput> {
if let Some(raw) = targets {
if raw.split(',').all(|tok| tok.trim().is_empty()) {
return Err(McpToolError::InvalidArgument(
"targets: empty or whitespace-only — supply at least one target name (e.g. \"claude-code,cursor\")".into(),
));
}
}
let selected = PackTargets::from_csv(targets)
.map_err(|e| McpToolError::InvalidArgument(format!("targets: {e}")))?;
let config = PackConfig {
target: target.clone(),
output_mode: OutputMode::DryRun,
selected_targets: selected,
doctrine_root: Some(cfg.doctrine_root(target)),
llm_provider: Some("none".to_string()),
ollama_model: None,
quiet: true,
from_cortex_push: false,
cortex_receipt_requested_explicitly: targets
.is_some_and(|raw| raw.split(',').any(|tok| tok.trim() == "cortex-receipt")),
};
let pack = pack_cmd::run(&config).map_err(McpToolError::from_anyhow)?;
let planned_outputs = pack
.outputs
.iter()
.map(|o| PlannedOutput {
path: o.path.clone(),
target: o.target.clone(),
sha256: o.sha256.clone(),
bytes: o.bytes,
managed: o.managed,
source_anchors: o.source_anchors.clone(),
})
.collect();
Ok(PackDryRunOutput {
schema: "cordance-pack-dry-run.v1".to_string(),
project_name: pack.project.name.clone(),
planned_outputs,
source_count: pack.sources.len(),
doctrine_commit: pack.doctrine_pins.first().map(|p| p.commit.clone()),
residual_risk: pack.residual_risk.clone(),
})
}
pub fn build_pack(target: &Utf8PathBuf, cfg: &Config) -> McpToolResult<CordancePack> {
build_pack_with_cortex_receipt_context(target, cfg, false)
}
pub fn build_pack_for_cortex_receipt(
target: &Utf8PathBuf,
cfg: &Config,
) -> McpToolResult<CordancePack> {
build_pack_with_cortex_receipt_context(target, cfg, true)
}
fn build_pack_with_cortex_receipt_context(
target: &Utf8PathBuf,
cfg: &Config,
from_cortex_push: bool,
) -> McpToolResult<CordancePack> {
let config = PackConfig {
target: target.clone(),
output_mode: OutputMode::DryRun,
selected_targets: PackTargets {
claude_code: true,
cursor: true,
codex: true,
axiom_harness_target: true,
cortex_receipt: true,
},
doctrine_root: Some(cfg.doctrine_root(target)),
llm_provider: Some("none".to_string()),
ollama_model: None,
quiet: true,
from_cortex_push,
cortex_receipt_requested_explicitly: from_cortex_push,
};
pack_cmd::run(&config).map_err(McpToolError::from_anyhow)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dry_run_rejects_empty_string_targets() {
let tmp = tempfile::tempdir().expect("tempdir");
let target = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).expect("tempdir is utf8");
let cfg = Config::default();
let result = dry_run(&target, &cfg, Some(""));
let Err(err) = result else {
panic!("expected InvalidArgument on empty targets; got Ok");
};
match err {
McpToolError::InvalidArgument(msg) => {
assert!(
msg.contains("empty or whitespace-only"),
"error message must explain the cause; got: {msg}"
);
}
other => panic!("expected InvalidArgument; got {other:?}"),
}
}
#[test]
fn dry_run_rejects_comma_only_targets() {
let tmp = tempfile::tempdir().expect("tempdir");
let target = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).expect("tempdir is utf8");
let cfg = Config::default();
for sample in &[",", " , , ", " ", ", ,"] {
let result = dry_run(&target, &cfg, Some(sample));
assert!(
matches!(result, Err(McpToolError::InvalidArgument(_))),
"expected InvalidArgument for targets={sample:?}; got {result:?}"
);
}
}
#[test]
fn cli_from_csv_empty_string_still_defaults_to_all() {
let parsed = PackTargets::from_csv(Some(""))
.expect("CLI parser must keep its lenient default for empty input");
assert!(
parsed.claude_code
&& parsed.cursor
&& parsed.codex
&& parsed.axiom_harness_target
&& parsed.cortex_receipt,
"PackTargets::from_csv(Some(\"\")) must default to all-targets; got {parsed:?}"
);
}
#[test]
fn dry_run_default_targets_omits_cortex_receipt_noop_warning() {
let tmp = tempfile::tempdir().expect("tempdir");
let target = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).expect("tempdir is utf8");
let doctrine = tempfile::tempdir().expect("doctrine tempdir");
let cfg = Config {
doctrine: crate::config::DoctrineConfig {
source: doctrine.path().to_string_lossy().into_owned(),
fallback_repo: "https://nonexistent.example.invalid/doctrine".into(),
pin_commit: "auto".into(),
},
..Default::default()
};
let output = dry_run(&target, &cfg, None).expect("MCP dry-run should build");
let needle = "cortex-receipt requested via --targets";
assert!(
!output
.residual_risk
.iter()
.any(|risk| risk.contains(needle)),
"omitted MCP targets default to all but must not pretend cortex-receipt was explicit: {:?}",
output.residual_risk
);
}
}