Skip to main content

agent_docs/commands/
baseline.rs

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}