1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use crate::config::load_configs_from_roots;
5use crate::env::ResolvedRoots;
6use crate::model::{
7 BaselineCheckItem, BaselineCheckReport, BaselineTarget, ConfigDocumentEntry, ConfigLoadError,
8 ConfigScopeFile, Context, DocumentSource, DocumentStatus, FallbackMode, Scope,
9};
10use crate::paths::normalize_path;
11
12pub fn check_builtin_baseline(
13 target: BaselineTarget,
14 roots: &ResolvedRoots,
15 strict: bool,
16) -> Result<BaselineCheckReport, ConfigLoadError> {
17 check_builtin_baseline_with_mode(target, roots, strict, FallbackMode::Auto)
18}
19
20pub fn check_builtin_baseline_with_mode(
21 target: BaselineTarget,
22 roots: &ResolvedRoots,
23 strict: bool,
24 fallback_mode: FallbackMode,
25) -> Result<BaselineCheckReport, ConfigLoadError> {
26 let mut items = builtin_items_for_target(target, roots, fallback_mode);
27 let builtin_keys: HashSet<BaselineKey> = items.iter().map(BaselineKey::from_item).collect();
28
29 let configs = load_configs_from_roots(roots)?;
30 let configs_in_load_order = configs.in_load_order();
31 items.extend(required_extension_items(
32 target,
33 roots,
34 fallback_mode,
35 &configs_in_load_order,
36 &builtin_keys,
37 ));
38
39 let suggested_actions = suggested_actions(&items);
40
41 Ok(BaselineCheckReport::from_items(
42 target,
43 strict,
44 roots.agent_home.clone(),
45 roots.project_path.clone(),
46 items,
47 suggested_actions,
48 ))
49}
50
51fn builtin_items_for_target(
52 target: BaselineTarget,
53 roots: &ResolvedRoots,
54 fallback_mode: FallbackMode,
55) -> Vec<BaselineCheckItem> {
56 let mut items = Vec::new();
57 match target {
58 BaselineTarget::Home => items.extend(home_items(roots)),
59 BaselineTarget::Project => items.extend(project_items(roots, fallback_mode)),
60 BaselineTarget::All => {
61 items.extend(home_items(roots));
62 items.extend(project_items(roots, fallback_mode));
63 }
64 }
65 items
66}
67
68fn home_items(roots: &ResolvedRoots) -> Vec<BaselineCheckItem> {
69 vec![
70 startup_policy_item(Scope::Home, &roots.agent_home, None),
71 required_item(
72 Scope::Home,
73 Context::SkillDev,
74 "skill-dev",
75 &roots.agent_home,
76 "DEVELOPMENT.md",
77 "skill development guidance from AGENT_HOME/DEVELOPMENT.md",
78 DocumentSource::Builtin,
79 ),
80 required_item(
81 Scope::Home,
82 Context::TaskTools,
83 "task-tools",
84 &roots.agent_home,
85 "CLI_TOOLS.md",
86 "tool-selection guidance from AGENT_HOME/CLI_TOOLS.md",
87 DocumentSource::Builtin,
88 ),
89 ]
90}
91
92fn project_items(roots: &ResolvedRoots, fallback_mode: FallbackMode) -> Vec<BaselineCheckItem> {
93 let project_fallback_root = project_fallback_root(roots, fallback_mode);
94 vec![
95 startup_policy_item(Scope::Project, &roots.project_path, project_fallback_root),
96 project_dev_item(&roots.project_path, project_fallback_root),
97 ]
98}
99
100fn startup_policy_item(
101 scope: Scope,
102 root: &Path,
103 fallback_root: Option<&Path>,
104) -> BaselineCheckItem {
105 let override_path = normalize_path(&root.join("AGENTS.override.md"));
106 if override_path.exists() {
107 return BaselineCheckItem {
108 scope,
109 context: Context::Startup,
110 label: "startup policy".to_string(),
111 path: override_path,
112 required: true,
113 status: DocumentStatus::Present,
114 source: DocumentSource::Builtin,
115 why: format!(
116 "startup {} policy (AGENTS.override.md preferred over AGENTS.md)",
117 scope
118 ),
119 };
120 }
121
122 let local_agents = normalize_path(&root.join("AGENTS.md"));
123 if local_agents.exists() {
124 return BaselineCheckItem {
125 scope,
126 context: Context::Startup,
127 label: "startup policy".to_string(),
128 path: local_agents,
129 required: true,
130 status: DocumentStatus::Present,
131 source: DocumentSource::BuiltinFallback,
132 why: format!(
133 "startup {} policy (AGENTS.override.md missing, fallback AGENTS.md)",
134 scope
135 ),
136 };
137 }
138
139 if let Some(fallback_root) = fallback_root {
140 let fallback_override = normalize_path(&fallback_root.join("AGENTS.override.md"));
141 if fallback_override.exists() {
142 return BaselineCheckItem {
143 scope,
144 context: Context::Startup,
145 label: "startup policy".to_string(),
146 path: fallback_override,
147 required: true,
148 status: DocumentStatus::Present,
149 source: DocumentSource::Builtin,
150 why: format!(
151 "startup {} policy (local missing, fallback to primary AGENTS.override.md)",
152 scope
153 ),
154 };
155 }
156
157 let fallback_agents = normalize_path(&fallback_root.join("AGENTS.md"));
158 if fallback_agents.exists() {
159 return BaselineCheckItem {
160 scope,
161 context: Context::Startup,
162 label: "startup policy".to_string(),
163 path: fallback_agents,
164 required: true,
165 status: DocumentStatus::Present,
166 source: DocumentSource::BuiltinFallback,
167 why: format!(
168 "startup {} policy (local missing, fallback to primary AGENTS.md)",
169 scope
170 ),
171 };
172 }
173 }
174
175 BaselineCheckItem {
176 scope,
177 context: Context::Startup,
178 label: "startup policy".to_string(),
179 path: local_agents,
180 required: true,
181 status: DocumentStatus::Missing,
182 source: DocumentSource::BuiltinFallback,
183 why: format!(
184 "startup {} policy (AGENTS.override.md missing, fallback AGENTS.md)",
185 scope
186 ),
187 }
188}
189
190fn required_item(
191 scope: Scope,
192 context: Context,
193 label: &str,
194 root: &Path,
195 file_name: &str,
196 why: &str,
197 source: DocumentSource,
198) -> BaselineCheckItem {
199 let path = normalize_path(&root.join(file_name));
200 let status = if path.exists() {
201 DocumentStatus::Present
202 } else {
203 DocumentStatus::Missing
204 };
205
206 BaselineCheckItem {
207 scope,
208 context,
209 label: label.to_string(),
210 path,
211 required: true,
212 status,
213 source,
214 why: why.to_string(),
215 }
216}
217
218fn project_dev_item(root: &Path, fallback_root: Option<&Path>) -> BaselineCheckItem {
219 let local_path = normalize_path(&root.join("DEVELOPMENT.md"));
220 if local_path.exists() {
221 return BaselineCheckItem {
222 scope: Scope::Project,
223 context: Context::ProjectDev,
224 label: "project-dev".to_string(),
225 path: local_path,
226 required: true,
227 status: DocumentStatus::Present,
228 source: DocumentSource::Builtin,
229 why: "project development guidance from PROJECT_PATH/DEVELOPMENT.md".to_string(),
230 };
231 }
232
233 if let Some(fallback_root) = fallback_root {
234 let fallback_path = normalize_path(&fallback_root.join("DEVELOPMENT.md"));
235 if fallback_path.exists() {
236 return BaselineCheckItem {
237 scope: Scope::Project,
238 context: Context::ProjectDev,
239 label: "project-dev".to_string(),
240 path: fallback_path,
241 required: true,
242 status: DocumentStatus::Present,
243 source: DocumentSource::Builtin,
244 why: "project development guidance from PROJECT_PATH/DEVELOPMENT.md (fallback to primary worktree)".to_string(),
245 };
246 }
247 }
248
249 BaselineCheckItem {
250 scope: Scope::Project,
251 context: Context::ProjectDev,
252 label: "project-dev".to_string(),
253 path: local_path,
254 required: true,
255 status: DocumentStatus::Missing,
256 source: DocumentSource::Builtin,
257 why: "project development guidance from PROJECT_PATH/DEVELOPMENT.md".to_string(),
258 }
259}
260
261fn required_extension_items(
262 target: BaselineTarget,
263 roots: &ResolvedRoots,
264 fallback_mode: FallbackMode,
265 configs_in_load_order: &[&ConfigScopeFile],
266 builtin_keys: &HashSet<BaselineKey>,
267) -> Vec<BaselineCheckItem> {
268 let mut extension_items = Vec::new();
269 let mut extension_indices: HashMap<BaselineKey, usize> = HashMap::new();
270
271 for config in configs_in_load_order {
272 merge_required_extension_items(
273 target,
274 roots,
275 fallback_mode,
276 config,
277 builtin_keys,
278 &mut extension_items,
279 &mut extension_indices,
280 );
281 }
282
283 extension_items
284}
285
286fn merge_required_extension_items(
287 target: BaselineTarget,
288 roots: &ResolvedRoots,
289 fallback_mode: FallbackMode,
290 config: &ConfigScopeFile,
291 builtin_keys: &HashSet<BaselineKey>,
292 extension_items: &mut Vec<BaselineCheckItem>,
293 extension_indices: &mut HashMap<BaselineKey, usize>,
294) {
295 for (index, entry) in config.documents.iter().enumerate() {
296 if !entry.required || !target_includes_scope(target, entry.scope) {
297 continue;
298 }
299
300 let path = resolve_extension_path_with_project_fallback(entry, roots, fallback_mode);
301 let key = BaselineKey::new(entry.context, entry.scope, path.clone());
302 if builtin_keys.contains(&key) {
303 continue;
304 }
305
306 let item = BaselineCheckItem {
307 scope: entry.scope,
308 context: entry.context,
309 label: entry.context.as_str().to_string(),
310 path: path.clone(),
311 required: true,
312 status: if path.exists() {
313 DocumentStatus::Present
314 } else {
315 DocumentStatus::Missing
316 },
317 source: extension_source(config.source_scope),
318 why: extension_why(config, index, entry),
319 };
320
321 if let Some(existing_index) = extension_indices.get(&key).copied() {
322 extension_items[existing_index] = item;
323 } else {
324 let next_index = extension_items.len();
325 extension_items.push(item);
326 extension_indices.insert(key, next_index);
327 }
328 }
329}
330
331fn target_includes_scope(target: BaselineTarget, scope: Scope) -> bool {
332 match target {
333 BaselineTarget::Home => scope == Scope::Home,
334 BaselineTarget::Project => scope == Scope::Project,
335 BaselineTarget::All => true,
336 }
337}
338
339fn extension_source(source_scope: Scope) -> DocumentSource {
340 match source_scope {
341 Scope::Home => DocumentSource::ExtensionHome,
342 Scope::Project => DocumentSource::ExtensionProject,
343 }
344}
345
346fn resolve_extension_path(entry: &ConfigDocumentEntry, roots: &ResolvedRoots) -> PathBuf {
347 let root = match entry.scope {
348 Scope::Home => &roots.agent_home,
349 Scope::Project => &roots.project_path,
350 };
351 normalize_path(&root.join(&entry.path))
352}
353
354fn resolve_extension_path_with_project_fallback(
355 entry: &ConfigDocumentEntry,
356 roots: &ResolvedRoots,
357 fallback_mode: FallbackMode,
358) -> PathBuf {
359 let local_path = resolve_extension_path(entry, roots);
360 if local_path.exists()
361 || !should_use_project_fallback(entry.scope, entry.required, fallback_mode)
362 {
363 return local_path;
364 }
365
366 let Some(primary_root) = project_fallback_root(roots, fallback_mode) else {
367 return local_path;
368 };
369
370 let fallback_path = normalize_path(&primary_root.join(&entry.path));
371 if fallback_path.exists() {
372 fallback_path
373 } else {
374 local_path
375 }
376}
377
378fn extension_why(config: &ConfigScopeFile, index: usize, entry: &ConfigDocumentEntry) -> String {
379 match entry
380 .notes
381 .as_deref()
382 .map(str::trim)
383 .filter(|notes| !notes.is_empty())
384 {
385 Some(notes) => format!(
386 "extension document from {} document[{index}] notes={notes}",
387 config.file_path.display()
388 ),
389 None => format!(
390 "extension document from {} document[{index}]",
391 config.file_path.display()
392 ),
393 }
394}
395
396#[derive(Debug, Clone, PartialEq, Eq, Hash)]
397struct BaselineKey {
398 context: &'static str,
399 scope: &'static str,
400 path: PathBuf,
401}
402
403impl BaselineKey {
404 fn new(context: Context, scope: Scope, path: PathBuf) -> Self {
405 Self {
406 context: context.as_str(),
407 scope: scope.as_str(),
408 path,
409 }
410 }
411
412 fn from_item(item: &BaselineCheckItem) -> Self {
413 Self::new(item.context, item.scope, item.path.clone())
414 }
415}
416
417fn suggested_actions(items: &[BaselineCheckItem]) -> Vec<String> {
418 let has_home_missing_required = items.iter().any(|item| {
419 item.scope == Scope::Home && item.required && matches!(item.status, DocumentStatus::Missing)
420 });
421 let has_project_missing_required = items.iter().any(|item| {
422 item.scope == Scope::Project
423 && item.required
424 && matches!(item.status, DocumentStatus::Missing)
425 });
426
427 let mut actions = Vec::new();
428 if has_home_missing_required {
429 actions.push("agent-docs scaffold-baseline --missing-only --target home".to_string());
430 }
431 if has_project_missing_required {
432 actions.push("agent-docs scaffold-baseline --missing-only --target project".to_string());
433 }
434
435 actions
436}
437
438fn project_fallback_root(roots: &ResolvedRoots, fallback_mode: FallbackMode) -> Option<&Path> {
439 if fallback_mode == FallbackMode::Auto && roots.is_linked_worktree {
440 roots.primary_worktree_path.as_deref()
441 } else {
442 None
443 }
444}
445
446fn should_use_project_fallback(scope: Scope, required: bool, fallback_mode: FallbackMode) -> bool {
447 scope == Scope::Project && required && fallback_mode == FallbackMode::Auto
448}