use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::compiler::agents::HarnessKind;
use crate::error::{ConfigError, MarsError};
use crate::lock::CompiledNativeOutput;
const NATIVE_AGENT_MANIFEST_VERSION: u32 = 1;
const NATIVE_AGENT_MANIFEST_FILENAME: &str = "native-agents.json";
#[derive(Debug, Serialize, Deserialize)]
struct NativeAgentManifestFile {
version: u32,
agents: BTreeMap<String, Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum NativeAgentManifestRead {
Found(BTreeMap<String, Vec<String>>),
Missing,
Unreadable,
}
fn agent_name_from_native_dest(path: &str) -> Option<String> {
let name = path.strip_prefix("agents/")?;
let stem = Path::new(name).file_stem()?.to_str()?;
if stem.is_empty() {
return None;
}
Some(stem.to_string())
}
fn compiled_native_outputs_from_lock(lock: &crate::lock::LockFile) -> Vec<CompiledNativeOutput> {
use crate::lock::{CANONICAL_TARGET_ROOT, ItemKind};
let mut records = Vec::new();
for item in lock.items.values() {
if item.kind != ItemKind::Agent {
continue;
}
let Some(owner_canonical_dest_path) = item
.outputs
.iter()
.find(|output| {
output.target_root == CANONICAL_TARGET_ROOT
&& output.dest_path.as_str().starts_with("agents/")
})
.map(|output| output.dest_path.to_string())
else {
continue;
};
for output in &item.outputs {
if output.target_root == CANONICAL_TARGET_ROOT {
continue;
}
if HarnessKind::from_target_dir(&output.target_root).is_none() {
continue;
}
records.push(CompiledNativeOutput {
owner_canonical_dest_path: owner_canonical_dest_path.clone(),
target_root: output.target_root.clone(),
dest_path: output.dest_path.to_string(),
installed_checksum: output.installed_checksum.clone(),
});
}
}
records
}
fn manifest_agents_from_records(records: &[CompiledNativeOutput]) -> BTreeMap<String, Vec<String>> {
let mut agents: BTreeMap<String, Vec<String>> = BTreeMap::new();
for record in records {
let Some(agent_name) = agent_name_from_native_dest(&record.dest_path) else {
continue;
};
let Some(harness_kind) = HarnessKind::from_target_dir(&record.target_root) else {
continue;
};
let harness = harness_kind.to_harness_id().as_str().to_string();
let harnesses = agents.entry(agent_name).or_default();
if !harnesses.iter().any(|existing| existing == &harness) {
harnesses.push(harness);
}
}
for harnesses in agents.values_mut() {
harnesses.sort();
}
agents
}
fn manifest_path(mars_dir: &Path) -> PathBuf {
mars_dir.join(NATIVE_AGENT_MANIFEST_FILENAME)
}
fn read_native_agent_manifest_state(mars_dir: &Path) -> NativeAgentManifestRead {
let path = manifest_path(mars_dir);
let content = match std::fs::read_to_string(&path) {
Ok(content) => content,
Err(_) => return NativeAgentManifestRead::Missing,
};
let parsed: NativeAgentManifestFile = match serde_json::from_str(&content) {
Ok(parsed) => parsed,
Err(_) => return NativeAgentManifestRead::Unreadable,
};
if parsed.version != NATIVE_AGENT_MANIFEST_VERSION {
return NativeAgentManifestRead::Unreadable;
}
NativeAgentManifestRead::Found(parsed.agents)
}
fn write_native_agent_manifest_file(
project_root: &Path,
manifest: &NativeAgentManifestFile,
) -> Result<(), MarsError> {
let mars_dir = project_root.join(".mars");
std::fs::create_dir_all(&mars_dir)?;
let path = manifest_path(&mars_dir);
let json = serde_json::to_string_pretty(manifest).map_err(|err| {
MarsError::Config(ConfigError::Invalid {
message: format!("failed to serialize native agent manifest: {err}"),
})
})?;
crate::fs::atomic_write(&path, json.as_bytes())
}
pub fn write_native_agent_manifest_from_lock(
project_root: &Path,
lock: &crate::lock::LockFile,
) -> Result<(), MarsError> {
let manifest = NativeAgentManifestFile {
version: NATIVE_AGENT_MANIFEST_VERSION,
agents: manifest_agents_from_records(&compiled_native_outputs_from_lock(lock)),
};
write_native_agent_manifest_file(project_root, &manifest)
}
pub fn persist_lock_then_native_agent_manifest(
project_root: &Path,
lock: &crate::lock::LockFile,
) -> Result<Option<String>, MarsError> {
crate::lock::write(project_root, lock)?;
match write_native_agent_manifest_from_lock(project_root, lock) {
Ok(()) => Ok(None),
Err(err) => Ok(Some(format!(
"could not write native agent manifest: {err}"
))),
}
}
pub fn read_native_agent_manifest(mars_dir: &Path) -> BTreeMap<String, Vec<String>> {
match read_native_agent_manifest_state(mars_dir) {
NativeAgentManifestRead::Found(agents) => agents,
NativeAgentManifestRead::Missing | NativeAgentManifestRead::Unreadable => BTreeMap::new(),
}
}
pub fn agent_is_native_for_harness(
manifest: &BTreeMap<String, Vec<String>>,
agent_name: &str,
harness: &str,
) -> bool {
manifest
.get(agent_name)
.is_some_and(|harnesses| harnesses.iter().any(|entry| entry == harness))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compiler::agents::HarnessKind;
use crate::lock::{ItemKind, LockFile, LockedItemV2, OutputRecord};
use tempfile::TempDir;
fn lock_with_agent_outputs(
agent_key: &str,
canonical_dest: &str,
native_targets: &[(&str, &str)],
) -> LockFile {
let mut lock = LockFile::empty();
let mut outputs = vec![OutputRecord {
target_root: ".mars".to_string(),
dest_path: canonical_dest.into(),
installed_checksum: "sha256:src".into(),
}];
for (target_root, dest_path) in native_targets {
outputs.push(OutputRecord {
target_root: (*target_root).to_string(),
dest_path: (*dest_path).into(),
installed_checksum: "sha256:native".into(),
});
}
lock.items.insert(
agent_key.to_string(),
LockedItemV2 {
source: "test".into(),
kind: ItemKind::Agent,
version: None,
source_checksum: "sha256:src".into(),
outputs,
},
);
lock
}
#[test]
fn manifest_round_trips_through_read() {
let dir = TempDir::new().unwrap();
let lock = lock_with_agent_outputs(
"agent/coder",
"agents/coder.md",
&[
(HarnessKind::Claude.target_dir(), "agents/coder.md"),
(HarnessKind::Codex.target_dir(), "agents/coder.toml"),
],
);
let lock = {
let mut lock = lock;
lock.items.insert(
"agent/frontend-coder".to_string(),
LockedItemV2 {
source: "test".into(),
kind: ItemKind::Agent,
version: None,
source_checksum: "sha256:src".into(),
outputs: vec![
OutputRecord {
target_root: ".mars".to_string(),
dest_path: "agents/frontend-coder.md".into(),
installed_checksum: "sha256:src".into(),
},
OutputRecord {
target_root: HarnessKind::Claude.target_dir().to_string(),
dest_path: "agents/frontend-coder.md".into(),
installed_checksum: "sha256:native".into(),
},
],
},
);
lock
};
write_native_agent_manifest_from_lock(dir.path(), &lock).unwrap();
let manifest = read_native_agent_manifest(&dir.path().join(".mars"));
let coder_harnesses: Option<Vec<&str>> = manifest
.get("coder")
.map(|v| v.iter().map(String::as_str).collect());
assert_eq!(
coder_harnesses.as_deref(),
Some(["claude", "codex"].as_slice())
);
let frontend_harnesses: Option<Vec<&str>> = manifest
.get("frontend-coder")
.map(|v| v.iter().map(String::as_str).collect());
assert_eq!(frontend_harnesses.as_deref(), Some(["claude"].as_slice()));
}
#[test]
fn manifest_from_lock_reflects_authoritative_lock_not_stale_file() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".mars")).unwrap();
std::fs::write(
dir.path().join(".mars").join("native-agents.json"),
r#"{"version":1,"agents":{"coder":["codex"]}}"#,
)
.unwrap();
let lock = lock_with_agent_outputs(
"agent/coder",
"agents/coder.md",
&[(HarnessKind::Claude.target_dir(), "agents/coder.md")],
);
write_native_agent_manifest_from_lock(dir.path(), &lock).unwrap();
let manifest = read_native_agent_manifest(&dir.path().join(".mars"));
let coder_harnesses: Option<Vec<&str>> = manifest
.get("coder")
.map(|v| v.iter().map(String::as_str).collect());
assert_eq!(coder_harnesses.as_deref(), Some(["claude"].as_slice()));
assert!(
!manifest
.get("coder")
.is_some_and(|h| h.iter().any(|harness| harness == "codex")),
"stale codex entry must be replaced by lock projection"
);
}
#[test]
fn manifest_uses_logical_name_from_native_dest_not_canonical_filename() {
let dir = TempDir::new().unwrap();
let lock = lock_with_agent_outputs(
"agent/my-file",
"agents/my-file.md",
&[(HarnessKind::Claude.target_dir(), "agents/logical-name.md")],
);
write_native_agent_manifest_from_lock(dir.path(), &lock).unwrap();
let manifest = read_native_agent_manifest(&dir.path().join(".mars"));
let logical_harnesses: Option<Vec<&str>> = manifest
.get("logical-name")
.map(|v| v.iter().map(String::as_str).collect());
assert_eq!(logical_harnesses.as_deref(), Some(["claude"].as_slice()));
assert!(
!manifest.contains_key("my-file"),
"manifest must not key by canonical filename when native dest uses profile name"
);
}
#[test]
fn agent_is_native_for_harness_matches_manifest_membership() {
let mut manifest = BTreeMap::new();
manifest.insert("coder".to_string(), vec!["claude".to_string()]);
assert!(agent_is_native_for_harness(&manifest, "coder", "claude"));
assert!(!agent_is_native_for_harness(&manifest, "coder", "codex"));
assert!(!agent_is_native_for_harness(
&manifest, "explorer", "claude"
));
}
#[test]
fn manifest_json_keys_are_sorted() {
let dir = TempDir::new().unwrap();
let lock = lock_with_agent_outputs(
"agent/z-agent",
"agents/z-agent.md",
&[(HarnessKind::Claude.target_dir(), "agents/z-agent.md")],
);
let lock = {
let mut lock = lock;
lock.items.insert(
"agent/a-agent".to_string(),
LockedItemV2 {
source: "test".into(),
kind: ItemKind::Agent,
version: None,
source_checksum: "sha256:src".into(),
outputs: vec![
OutputRecord {
target_root: ".mars".to_string(),
dest_path: "agents/a-agent.md".into(),
installed_checksum: "sha256:src".into(),
},
OutputRecord {
target_root: HarnessKind::Claude.target_dir().to_string(),
dest_path: "agents/a-agent.md".into(),
installed_checksum: "sha256:native".into(),
},
],
},
);
lock
};
write_native_agent_manifest_from_lock(dir.path(), &lock).unwrap();
let json = std::fs::read_to_string(dir.path().join(".mars/native-agents.json")).unwrap();
assert!(
json.find("\"a-agent\"").unwrap() < json.find("\"z-agent\"").unwrap(),
"manifest JSON must emit agent keys in sorted order"
);
}
}