use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use serde_json::json;
use crate::error::Result;
use crate::{
ASD_PATH_PREFIX, AsgIndexStore, AsgLedgerStore, Author, AuthorKind, Engine, IndexStore,
LedgerEntry, LedgerKind, LedgerStore, RoleTag, Symbol,
};
pub fn run_map(
engine: &Engine,
agent_id: &str,
db_parent: Option<&Path>,
dry_run: bool,
) -> Result<serde_json::Value> {
let index = AsgIndexStore::from_engine(engine);
let ledger = AsgLedgerStore::from_engine(engine);
let ref_name = engine.ref_name.clone();
let prefix = format!("{}/index/by-qname", ASD_PATH_PREFIX);
let tree = engine
.repo
.get_tree(&ref_name, &prefix)
.unwrap_or(serde_json::Value::Null);
let qnames: Vec<String> = match tree {
serde_json::Value::Object(m) => m.keys().cloned().collect(),
_ => Vec::new(),
};
let mut all_symbols: Vec<Symbol> = Vec::new();
for qn in qnames {
if let Ok(Some(sym)) = index.get_symbol_by_qname(&ref_name, &qn) {
all_symbols.push(sym);
}
}
let mut package_front_doors: BTreeMap<String, Symbol> = BTreeMap::new();
for sym in &all_symbols {
if !is_package_member(&sym.file) {
continue;
}
let pkg = package_dir(&sym.file);
package_front_doors
.entry(pkg)
.and_modify(|existing| {
if sym.qname.len() < existing.qname.len() {
*existing = sym.clone();
}
})
.or_insert_with(|| sym.clone());
}
let mut test_file_roles: BTreeMap<String, (Symbol, RoleTag)> = BTreeMap::new();
for sym in &all_symbols {
if let Some(role) = test_file_role(&sym.file) {
test_file_roles
.entry(sym.file.clone())
.and_modify(|(s, _)| {
if sym.qname.len() < s.qname.len() {
*s = sym.clone();
}
})
.or_insert_with(|| (sym.clone(), role));
}
}
let mut written_pkg = 0usize;
let mut written_test = 0usize;
let mut packages_out: Vec<serde_json::Value> = Vec::new();
let mut test_files_out: Vec<serde_json::Value> = Vec::new();
let author = Author {
kind: AuthorKind::Agent,
id: agent_id.to_string(),
};
let ctx_task_id = read_active_ctx_task_id(db_parent);
for (pkg, sym) in &package_front_doors {
packages_out.push(json!({
"package": pkg,
"front_door_qname": sym.qname,
"file": sym.file,
"role": RoleTag::PackageBoundary.as_str(),
}));
if !dry_run {
write_map_entry(
&ledger,
&ref_name,
sym,
RoleTag::PackageBoundary,
&format!("package boundary: {pkg}"),
&author,
ctx_task_id.as_deref(),
)?;
written_pkg += 1;
}
}
for (file, (sym, role)) in &test_file_roles {
test_files_out.push(json!({
"file": file,
"qname": sym.qname,
"role": role.as_str(),
}));
if !dry_run {
let summary = match role {
RoleTag::FastTest => format!("fast test: {file}"),
RoleTag::DiagnosticTest => format!("diagnostic test (env-gate before CI): {file}"),
_ => format!("test: {file}"),
};
write_map_entry(
&ledger,
&ref_name,
sym,
*role,
&summary,
&author,
ctx_task_id.as_deref(),
)?;
written_test += 1;
}
}
let pkg_set: BTreeSet<&String> = package_front_doors.keys().collect();
Ok(json!({
"intent": "asd-map",
"dry_run": dry_run,
"indexed_symbols": all_symbols.len(),
"packages": packages_out,
"test_files": test_files_out,
"summary": {
"package_count": pkg_set.len(),
"test_file_count": test_file_roles.len(),
"entries_written": written_pkg + written_test,
},
}))
}
fn write_map_entry(
ledger: &AsgLedgerStore,
ref_name: &str,
sym: &Symbol,
role: RoleTag,
summary: &str,
author: &Author,
ctx_task_id: Option<&str>,
) -> Result<()> {
let mut entry = LedgerEntry::new(
&sym.symbol_id,
LedgerKind::Ownership,
summary,
author.clone(),
);
entry.entry_id = deterministic_entry_id(&sym.symbol_id, role.as_str());
entry.role = Some(role.as_str().to_string());
entry.tags.push("plan-c:asd-map".to_string());
if let Some(id) = ctx_task_id {
let tag = format!("ctx:task:{id}");
if !entry.tags.iter().any(|t| t == &tag) {
entry.tags.push(tag);
}
}
ledger.append_entry(ref_name, &entry, &author.id)?;
Ok(())
}
fn read_active_ctx_task_id(db_parent: Option<&Path>) -> Option<String> {
let raw = std::env::var("CTX_ACTIVE_TASK").ok().or_else(|| {
let p = db_parent?
.join(".asd")
.join("cache")
.join("active-task.json");
std::fs::read_to_string(p).ok()
})?;
let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
v.get("task_id")?.as_str().map(String::from)
}
fn deterministic_entry_id(sym_id: &str, role: &str) -> String {
let key = format!("asd-map:{sym_id}:{role}");
let h = blake3::hash(key.as_bytes()).to_hex();
let short: String = h.chars().take(24).collect();
format!("led_map_{short}")
}
fn is_package_member(file: &str) -> bool {
file.contains('/')
}
fn package_dir(file: &str) -> String {
match file.rsplit_once('/') {
Some((d, _)) => d.to_string(),
None => String::new(),
}
}
const DIAGNOSTIC_BODY_MARKERS: &[&str] = &[
"FileManager.default.fileExists",
"FileManager.fileExists",
"renderFullSong",
".trace(",
"batchRender",
"durationSeconds",
"/Users/", "tmp_path", "tempfile.NamedTemporaryFile",
"subprocess.run",
];
fn body_looks_diagnostic(file: &str) -> bool {
let path = Path::new(file);
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(_) => return false,
};
let limit = bytes.len().min(64 * 1024);
let head = match std::str::from_utf8(&bytes[..limit]) {
Ok(s) => s,
Err(_) => return false,
};
DIAGNOSTIC_BODY_MARKERS.iter().any(|m| head.contains(m))
}
fn test_file_role(file: &str) -> Option<RoleTag> {
let lower = file.to_lowercase();
let is_test = lower.contains("/test")
|| lower.contains("_test.")
|| lower.contains(".test.")
|| lower.contains("/spec/")
|| lower.ends_with("_spec.rb");
if !is_test {
return None;
}
let diagnostic_by_name = lower.contains("diagnostic")
|| lower.contains("integration")
|| lower.contains("e2e")
|| lower.contains("real_file")
|| lower.contains("slow");
let diagnostic = diagnostic_by_name || body_looks_diagnostic(file);
Some(if diagnostic {
RoleTag::DiagnosticTest
} else {
RoleTag::FastTest
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_file_role_classifies_filenames() {
assert_eq!(
test_file_role("tests/fast_test.py"),
Some(RoleTag::FastTest)
);
assert_eq!(
test_file_role("tests/diagnostic_real_file_test.py"),
Some(RoleTag::DiagnosticTest)
);
assert_eq!(
test_file_role("tests/integration_test.py"),
Some(RoleTag::DiagnosticTest)
);
assert_eq!(test_file_role("src/pkg/module.py"), None);
}
#[test]
fn body_looks_diagnostic_matches_session_drift_markers() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("foo_test.py");
std::fs::write(
&path,
"import subprocess\nsubprocess.run(['swift', 'test'])\n",
)
.unwrap();
assert!(body_looks_diagnostic(path.to_str().unwrap()));
}
#[test]
fn body_looks_diagnostic_false_for_clean_unit_test() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("foo_test.py");
std::fs::write(&path, "def test_addition():\n assert 1 + 1 == 2\n").unwrap();
assert!(!body_looks_diagnostic(path.to_str().unwrap()));
}
#[test]
fn body_looks_diagnostic_false_for_missing_file() {
assert!(!body_looks_diagnostic(
"/nonexistent/path/that/cannot/exist.py"
));
}
#[test]
fn read_active_ctx_task_id_extracts_from_file() {
let tmp = tempfile::tempdir().unwrap();
let cache_dir = tmp.path().join(".asd/cache");
std::fs::create_dir_all(&cache_dir).unwrap();
std::fs::write(
cache_dir.join("active-task.json"),
r#"{"task_id":"t-007","scope":["x/**"]}"#,
)
.unwrap();
assert_eq!(
read_active_ctx_task_id(Some(tmp.path())).as_deref(),
Some("t-007")
);
}
#[test]
fn read_active_ctx_task_id_returns_none_when_no_source() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(read_active_ctx_task_id(Some(tmp.path())), None);
}
#[test]
fn test_file_role_promotes_clean_test_to_diagnostic_on_body_marker() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("fast_test.py");
std::fs::write(
&path,
"import subprocess\ndef test_ok():\n subprocess.run(['ls'])\n",
)
.unwrap();
assert_eq!(
test_file_role(path.to_str().unwrap()),
Some(RoleTag::DiagnosticTest)
);
}
#[test]
fn deterministic_entry_id_is_stable() {
let a = deterministic_entry_id("sym_x", "fast-test");
let b = deterministic_entry_id("sym_x", "fast-test");
assert_eq!(a, b);
assert!(a.starts_with("led_map_"));
}
#[test]
fn deterministic_entry_id_changes_with_role() {
let a = deterministic_entry_id("sym_x", "fast-test");
let b = deterministic_entry_id("sym_x", "diagnostic-test");
assert_ne!(a, b);
}
#[test]
fn package_dir_returns_parent_directory() {
assert_eq!(package_dir("src/pkg/mod.py"), "src/pkg");
assert_eq!(package_dir("root.py"), "");
}
}