lean_ctx/core/
artifacts.rs1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct ArtifactRegistry {
7 pub artifacts: Vec<ArtifactSpec>,
8}
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ArtifactSpec {
12 pub name: String,
13 pub path: String,
14 pub description: String,
15 #[serde(default)]
16 pub tags: Vec<String>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct ResolvedArtifact {
21 pub name: String,
22 pub path: String,
23 pub description: String,
24 #[serde(default)]
25 pub tags: Vec<String>,
26 pub exists: bool,
27 pub is_dir: bool,
28}
29
30#[derive(Debug, Default, Serialize)]
31pub struct ResolvedArtifacts {
32 pub artifacts: Vec<ResolvedArtifact>,
33 pub warnings: Vec<String>,
34}
35
36pub fn load_resolved(project_root: &Path) -> ResolvedArtifacts {
37 let mut out = ResolvedArtifacts::default();
38 let root_canon = project_root
39 .canonicalize()
40 .unwrap_or_else(|_| project_root.to_path_buf());
41
42 let Some((registry_path, content)) = read_registry_file(project_root) else {
43 return out;
44 };
45
46 let parsed = parse_registry_json(&content).unwrap_or_else(|e| {
47 out.warnings.push(format!(
48 "artifact registry parse failed ({}): {e}",
49 registry_path.display()
50 ));
51 ArtifactRegistry { artifacts: vec![] }
52 });
53
54 let mut seen = std::collections::HashSet::<String>::new();
55 for spec in parsed.artifacts {
56 let name = spec.name.trim().to_string();
57 if name.is_empty() {
58 continue;
59 }
60 if !seen.insert(name.clone()) {
61 continue;
62 }
63
64 let raw = spec.path.trim();
65 if raw.is_empty() {
66 continue;
67 }
68 let rel = normalize_rel_path(raw);
69 let candidate = if Path::new(&rel).is_absolute() {
70 PathBuf::from(&rel)
71 } else {
72 project_root.join(&rel)
73 };
74
75 let abs = match crate::core::io_boundary::jail_and_check_path(
76 "artifacts",
77 &candidate,
78 project_root,
79 ) {
80 Ok((p, _)) => p,
81 Err(e) => {
82 out.warnings
83 .push(format!("artifact path rejected ({name}): {rel} ({e})"));
84 continue;
85 }
86 };
87
88 let role = crate::core::roles::active_role();
91 if !role.io.allow_secret_paths {
92 if let Some(reason) = crate::core::io_boundary::is_secret_like(&abs) {
93 let role_name = crate::core::roles::active_role_name();
94 let msg = format!(
95 "artifact rejected ({name}): {rel} (secret-like path: {reason}; role: {role_name})"
96 );
97 crate::core::events::emit_policy_violation(&role_name, "artifacts", &msg);
98 out.warnings.push(msg);
99 continue;
100 }
101 }
102
103 let (exists, is_dir) = match abs.metadata() {
104 Ok(m) => (true, m.is_dir()),
105 Err(_) => (false, false),
106 };
107
108 let rel_out = abs
109 .strip_prefix(&root_canon)
110 .unwrap_or(&abs)
111 .to_string_lossy()
112 .to_string();
113
114 out.artifacts.push(ResolvedArtifact {
115 name,
116 path: rel_out,
117 description: spec.description.trim().to_string(),
118 tags: spec.tags,
119 exists,
120 is_dir,
121 });
122 }
123
124 out
125}
126
127fn read_registry_file(project_root: &Path) -> Option<(PathBuf, String)> {
128 let lean = project_root.join(".leanctxcontextartifacts.json");
129 if let Ok(s) = std::fs::read_to_string(&lean) {
130 return Some((lean, s));
131 }
132 let socrati = project_root.join(".socraticodecontextartifacts.json");
133 if let Ok(s) = std::fs::read_to_string(&socrati) {
134 return Some((socrati, s));
135 }
136 None
137}
138
139fn parse_registry_json(content: &str) -> Result<ArtifactRegistry, String> {
140 if let Ok(reg) = serde_json::from_str::<ArtifactRegistry>(content) {
141 return Ok(reg);
142 }
143 if let Ok(list) = serde_json::from_str::<Vec<ArtifactSpec>>(content) {
144 return Ok(ArtifactRegistry { artifacts: list });
145 }
146 Err("invalid JSON schema (expected { artifacts: [...] } or [...])".to_string())
147}
148
149fn normalize_rel_path(raw: &str) -> String {
150 let mut s = raw.trim().to_string();
151 while let Some(rest) = s.strip_prefix("./") {
152 s = rest.to_string();
153 }
154 s.trim_start_matches(['/', '\\']).to_string()
155}