Skip to main content

algocline_app/service/
hub_dist_preset.rs

1//! `alc_hub_dist` preset expansion (`Current` builtin recipes + optional
2//! `alc.toml` overrides).
3//!
4//! Design goals:
5//! - Keep `hub_gendoc` a primitive — presets are expanded only in `hub_dist`.
6//! - `preset_catalog_version` is a human-oriented revision marker for the
7//!   builtin recipe dictionary (not semver for individual presets).
8
9use std::collections::BTreeMap;
10use std::path::{Path, PathBuf};
11
12use serde::Deserialize;
13use serde_json::json;
14
15use super::gendoc::templates;
16use super::project::resolve_project_root;
17
18/// Revision marker for the builtin preset dictionary bundled with this
19/// binary. Bump when builtin recipes change (even if `CARGO_PKG_VERSION`
20/// does not).
21pub const PRESET_CATALOG_VERSION: &str = "preset-catalog@2026-04-23";
22
23#[derive(Debug, Clone)]
24pub struct HubDistPresetResolution {
25    pub projections: Option<Vec<String>>,
26    pub config_path: Option<String>,
27    pub lint_strict: Option<bool>,
28    pub catalog_version: String,
29    pub preset_name: Option<String>,
30    pub overrides_source: Vec<String>,
31    pub resolved_project_root: Option<PathBuf>,
32}
33
34// ── TOML deserialization structs ──────────────────────────────────────
35
36#[derive(Debug, Deserialize)]
37struct HubDistToml {
38    hub: Option<HubSection>,
39}
40
41/// Top-level `[hub]` section in `alc.toml`.
42///
43/// All fields use `#[serde(default)]` so that existing consumers that only
44/// define `[hub.dist]` continue to deserialize without error when the new
45/// `context7` / `devin` / `name` / `description` fields are absent.
46#[derive(Debug, Deserialize, Default)]
47struct HubSection {
48    dist: Option<HubDistSection>,
49    /// Shared project name propagated to context7/devin when their own
50    /// `name` field is absent.
51    #[serde(default)]
52    name: Option<String>,
53    /// Shared project description propagated to context7/devin when their
54    /// own `description` field is absent.
55    #[serde(default)]
56    description: Option<String>,
57    /// Context7 projection configuration (`[hub.context7]`).
58    #[serde(default)]
59    context7: Option<HubContext7Config>,
60    /// Devin wiki projection configuration (`[hub.devin]`).
61    #[serde(default)]
62    devin: Option<HubDevinConfig>,
63}
64
65#[derive(Debug, Deserialize)]
66struct HubDistSection {
67    preset_catalog_version: Option<String>,
68    presets: Option<BTreeMap<String, HubDistPresetOverride>>,
69}
70
71#[derive(Debug, Deserialize)]
72struct HubDistPresetOverride {
73    projections: Option<Vec<String>>,
74    config_path: Option<String>,
75    lint_strict: Option<bool>,
76}
77
78/// `[hub.context7]` TOML section — context7 projection overrides.
79#[derive(Debug, Deserialize, Default, Clone)]
80pub struct HubContext7Config {
81    /// Override project name for the context7 projection.
82    #[serde(default)]
83    pub name: Option<String>,
84    /// Override project description for the context7 projection.
85    #[serde(default)]
86    pub description: Option<String>,
87    /// Fully replace the default rules with this list (mutually exclusive
88    /// with `rules_file`).
89    #[serde(default)]
90    pub rules_override: Option<Vec<String>>,
91    /// Path to a file whose lines become the rules list (one rule per line,
92    /// blank lines and lines starting with `#` are ignored).  Mutually
93    /// exclusive with `rules_override`.
94    #[serde(default)]
95    pub rules_file: Option<String>,
96    /// Rules appended after the default (or overridden) list.
97    #[serde(default)]
98    pub extra_rules: Option<Vec<String>>,
99}
100
101/// `[hub.devin]` TOML section — Devin wiki projection overrides.
102///
103/// Only `repo_notes`-related fields are retained here because the DeepWiki
104/// schema (<https://docs.devin.ai/work-with-devin/deepwiki>) only documents
105/// `repo_notes` and `pages` as recognised top-level keys.  Identity fields
106/// (`project_name`, `description`) are not part of the schema and are
107/// therefore out of scope.
108#[derive(Debug, Deserialize, Default, Clone)]
109pub struct HubDevinConfig {
110    /// Fully replace the default repo notes with this list (mutually
111    /// exclusive with `repo_notes_file`).
112    #[serde(default)]
113    pub repo_notes_override: Option<Vec<String>>,
114    /// Path to a file whose lines become repo notes (one note per line,
115    /// blank lines and lines starting with `#` are ignored).  Mutually
116    /// exclusive with `repo_notes_override`.
117    #[serde(default)]
118    pub repo_notes_file: Option<String>,
119    /// Repo notes appended after the default (or overridden) list.
120    #[serde(default)]
121    pub extra_repo_notes: Option<Vec<String>>,
122}
123
124// ── Resolved output types ─────────────────────────────────────────────
125
126/// Resolved, merged configuration for a single context7 projection.
127#[derive(Debug, Clone)]
128pub struct ResolvedContext7 {
129    /// Project name to surface in the context7 output.
130    pub name: String,
131    /// Project description to surface in the context7 output.
132    pub description: String,
133    /// Merged rules list (default + extras, or overridden/file-sourced).
134    pub rules: Vec<String>,
135}
136
137/// Resolved, merged configuration for a single Devin wiki projection.
138#[derive(Debug, Clone)]
139pub struct ResolvedDevin {
140    /// Merged repo notes list stored as plain strings in memory.
141    ///
142    /// **Important**: when converting to `toml::Value` via
143    /// [`HubProjectionConfig::to_devin_toml`], each entry is wrapped into a
144    /// `{content = "<str>"}` inline table to satisfy the `validate_note`
145    /// contract in `projections.lua:664-670`.  Passing plain strings
146    /// produces a Lua runtime error at projection time.
147    pub repo_notes: Vec<String>,
148}
149
150/// Combined resolved configuration passed to `inject_config_subtable` for
151/// both the context7 and Devin projections.
152#[derive(Debug, Clone)]
153pub struct HubProjectionConfig {
154    pub context7: ResolvedContext7,
155    pub devin: ResolvedDevin,
156}
157
158impl HubProjectionConfig {
159    /// Convert the context7 configuration into a `toml::Value::Table`
160    /// suitable for passing to `inject_config_subtable` as the
161    /// `tools.docs.context7_config` preload.
162    ///
163    /// Shape: `{ projectTitle = "...", description = "...", rules = ["...", ...] }`
164    pub fn to_context7_toml(&self) -> toml::Value {
165        let mut map = toml::value::Table::new();
166        map.insert(
167            "projectTitle".to_string(),
168            toml::Value::String(self.context7.name.clone()),
169        );
170        map.insert(
171            "description".to_string(),
172            toml::Value::String(self.context7.description.clone()),
173        );
174        let rules: Vec<toml::Value> = self
175            .context7
176            .rules
177            .iter()
178            .map(|r| toml::Value::String(r.clone()))
179            .collect();
180        map.insert("rules".to_string(), toml::Value::Array(rules));
181        toml::Value::Table(map)
182    }
183
184    /// Convert the Devin configuration into a `toml::Value::Table`
185    /// suitable for passing to `inject_config_subtable` as the
186    /// `tools.docs.devin_wiki_config` preload.
187    ///
188    /// Each `repo_notes` string is wrapped into a `{content = "<str>"}` inline
189    /// table to satisfy `projections.lua:664-670` `validate_note`, which
190    /// requires `type(note) == "table"` with a `note.content` string field.
191    /// Passing plain strings produces a Lua runtime error at projection time.
192    ///
193    /// Only `repo_notes` is emitted.  Identity fields (`project_name`,
194    /// `description`) are not part of the DeepWiki schema
195    /// (<https://docs.devin.ai/work-with-devin/deepwiki>).
196    ///
197    /// Shape: `{ repo_notes = [{content = "..."}, ...] }`
198    pub fn to_devin_toml(&self) -> toml::Value {
199        let mut map = toml::value::Table::new();
200        // Wrap each plain string into {content = "<str>"} inline table.
201        let repo_notes: Vec<toml::Value> = self
202            .devin
203            .repo_notes
204            .iter()
205            .map(|s| {
206                let mut t = toml::value::Table::new();
207                t.insert("content".to_string(), toml::Value::String(s.clone()));
208                toml::Value::Table(t)
209            })
210            .collect();
211        map.insert("repo_notes".to_string(), toml::Value::Array(repo_notes));
212        toml::Value::Table(map)
213    }
214}
215
216// ── Helper: load + resolve hub projection config ──────────────────────
217
218/// Load `alc.toml` from `project_root` (if available) and resolve the
219/// `[hub.context7]` / `[hub.devin]` sections into a [`HubProjectionConfig`]
220/// using a three-stage precedence chain:
221///
222/// **name / description**:
223/// `[hub.context7].name` / `.description` > `[hub].name` / `.description` > core default
224///
225/// **rules** (context7):
226/// `rules_file` (full replacement) > `rules_override` (full replacement) >
227/// `core_default_rules ++ extra_rules`
228///
229/// **repo_notes** (devin):
230/// `repo_notes_file` (full replacement) > `repo_notes_override` (full replacement) >
231/// `core_default_repo_notes ++ extra_repo_notes`
232///
233/// `rules_file` and `rules_override` are mutually exclusive; likewise for
234/// `repo_notes_file` and `repo_notes_override`.  Both violations return a
235/// typed `Err` that propagates to the MCP wire layer.
236///
237/// When `project_root` is `None` or `alc.toml` is absent, the function
238/// returns a config built from core defaults only (not an error).
239pub fn load_hub_projection_config(
240    project_root: Option<&Path>,
241) -> Result<HubProjectionConfig, String> {
242    // 1. Try to read alc.toml; absence is legitimate (defaults apply).
243    let hub_section: Option<HubSection> = if let Some(root) = project_root {
244        let alc_path = root.join("alc.toml");
245        if alc_path.is_file() {
246            let raw = std::fs::read_to_string(&alc_path)
247                .map_err(|e| format!("gendoc: failed to read {}: {e}", alc_path.display()))?;
248            let parsed: HubDistToml =
249                toml::from_str(&raw).map_err(|e| format!("gendoc: alc.toml parse failed: {e}"))?;
250            parsed.hub
251        } else {
252            None
253        }
254    } else {
255        None
256    };
257
258    let hub = hub_section.as_ref();
259    let shared_name = hub.and_then(|h| h.name.as_deref());
260    let shared_description = hub.and_then(|h| h.description.as_deref());
261    let c7_cfg = hub.and_then(|h| h.context7.as_ref());
262    let dv_cfg = hub.and_then(|h| h.devin.as_ref());
263
264    // 2. Validate mutually exclusive fields.
265    if let Some(c7) = c7_cfg {
266        if c7.rules_file.is_some() && c7.rules_override.is_some() {
267            return Err("gendoc: rules_file and rules_override are mutually exclusive".to_string());
268        }
269    }
270    if let Some(dv) = dv_cfg {
271        if dv.repo_notes_file.is_some() && dv.repo_notes_override.is_some() {
272            return Err(
273                "gendoc: repo_notes_file and repo_notes_override are mutually exclusive"
274                    .to_string(),
275            );
276        }
277    }
278
279    // 3. Resolve context7.
280    let c7_name = c7_cfg
281        .and_then(|c| c.name.as_deref())
282        .or(shared_name)
283        .unwrap_or(templates::DEFAULT_NAME_FALLBACK)
284        .to_string();
285
286    let c7_description = c7_cfg
287        .and_then(|c| c.description.as_deref())
288        .or(shared_description)
289        .unwrap_or(templates::DEFAULT_C7_DESCRIPTION)
290        .to_string();
291
292    let c7_rules = resolve_rules(
293        c7_cfg.and_then(|c| c.rules_file.as_deref()),
294        c7_cfg.and_then(|c| c.rules_override.as_deref()),
295        c7_cfg.and_then(|c| c.extra_rules.as_deref()),
296        templates::DEFAULT_C7_RULES,
297        project_root,
298    )?;
299
300    // 4. Resolve devin.
301    let dv_repo_notes = resolve_rules(
302        dv_cfg.and_then(|d| d.repo_notes_file.as_deref()),
303        dv_cfg.and_then(|d| d.repo_notes_override.as_deref()),
304        dv_cfg.and_then(|d| d.extra_repo_notes.as_deref()),
305        templates::DEFAULT_DEVIN_REPO_NOTES,
306        project_root,
307    )?;
308
309    Ok(HubProjectionConfig {
310        context7: ResolvedContext7 {
311            name: c7_name,
312            description: c7_description,
313            rules: c7_rules,
314        },
315        devin: ResolvedDevin {
316            repo_notes: dv_repo_notes,
317        },
318    })
319}
320
321/// Resolve a `Vec<String>` list (rules or repo_notes) using the three-stage
322/// precedence chain:
323///
324/// 1. `file_path` — read file relative to `project_root`, split by lines,
325///    strip blanks and `#`-comments; full replacement.
326/// 2. `override_list` — use as-is; full replacement.
327/// 3. `default_list ++ extra` — core defaults concatenated with extras.
328fn resolve_rules(
329    file_path: Option<&str>,
330    override_list: Option<&[String]>,
331    extra: Option<&[String]>,
332    default_list: &[&str],
333    project_root: Option<&Path>,
334) -> Result<Vec<String>, String> {
335    if let Some(rel_path) = file_path {
336        // Resolve relative path against project_root; fall back to cwd.
337        let abs_path = if let Some(root) = project_root {
338            root.join(rel_path)
339        } else {
340            Path::new(rel_path).to_path_buf()
341        };
342        let content = std::fs::read_to_string(&abs_path).map_err(|e| {
343            format!(
344                "gendoc: rules_file '{}' load failed: {e}",
345                abs_path.display()
346            )
347        })?;
348        let lines: Vec<String> = content
349            .lines()
350            .map(|l| l.trim())
351            .filter(|l| !l.is_empty() && !l.starts_with('#'))
352            .map(|l| l.to_string())
353            .collect();
354        return Ok(lines);
355    }
356
357    if let Some(ov) = override_list {
358        return Ok(ov.to_vec());
359    }
360
361    // Default + extras.
362    let mut result: Vec<String> = default_list.iter().map(|s| s.to_string()).collect();
363    if let Some(ex) = extra {
364        result.extend(ex.iter().cloned());
365    }
366    Ok(result)
367}
368
369// ── Existing preset resolution (unchanged) ────────────────────────────
370
371pub fn resolve_hub_dist_preset(
372    preset: Option<&str>,
373    project_root: Option<&str>,
374    source_dir: &str,
375    projections: Option<&[String]>,
376    config_path: Option<&str>,
377    lint_strict: Option<bool>,
378) -> Result<HubDistPresetResolution, String> {
379    let mut overrides_source: Vec<String> = Vec::new();
380
381    let resolved_root = resolve_project_root(project_root);
382    if resolved_root.is_some() {
383        overrides_source.push("project_root".to_string());
384    }
385
386    let preset_name = preset.map(|s| s.trim()).filter(|s| !s.is_empty());
387
388    // Start from explicit caller knobs.
389    let caller_projections = projections.map(|p| p.to_vec());
390    let caller_config_path = config_path.map(|s| s.to_string());
391    let caller_lint_strict = lint_strict;
392
393    let mut eff_projections = caller_projections.clone();
394    let mut eff_config_path = caller_config_path.clone();
395    let mut eff_lint_strict = caller_lint_strict;
396
397    if let Some(name) = preset_name {
398        if name != "publish" {
399            return Err(format!(
400                "dist: unknown preset '{name}' (allowed: publish); bump {PRESET_CATALOG_VERSION} if adding presets"
401            ));
402        }
403    }
404
405    // Optional overrides from `alc.toml` at the resolved project root.
406    if let Some(root) = resolved_root.as_deref() {
407        let alc_path = root.join("alc.toml");
408        if alc_path.is_file() {
409            let raw = std::fs::read_to_string(&alc_path)
410                .map_err(|e| format!("dist: failed to read {}: {e}", alc_path.display()))?;
411            let parsed: HubDistToml =
412                toml::from_str(&raw).map_err(|e| format!("dist: failed to parse alc.toml: {e}"))?;
413
414            if let Some(hub) = parsed.hub.as_ref() {
415                if let Some(dist) = hub.dist.as_ref() {
416                    if let Some(v) = dist.preset_catalog_version.as_deref() {
417                        if !v.trim().is_empty() && v.trim() != PRESET_CATALOG_VERSION {
418                            // Doc-only marker today; still surface mismatches loudly so
419                            // hub repos don't silently assume a different catalog.
420                            return Err(format!(
421                                "dist: alc.toml hub.dist.preset_catalog_version={v:?} does not match builtin {PRESET_CATALOG_VERSION}"
422                            ));
423                        }
424                    }
425
426                    if let Some(name) = preset_name {
427                        if let Some(map) = dist.presets.as_ref() {
428                            if let Some(ov) = map.get(name) {
429                                overrides_source.push("alc.toml".to_string());
430
431                                // `alc.toml` may refine defaults, but explicit MCP args win.
432                                if caller_projections.is_none() {
433                                    if let Some(p) = ov.projections.as_ref() {
434                                        eff_projections = Some(p.clone());
435                                    }
436                                }
437
438                                if caller_config_path.is_none() {
439                                    eff_config_path = ov.config_path.clone();
440                                }
441
442                                if caller_lint_strict.is_none() {
443                                    eff_lint_strict = ov.lint_strict;
444                                }
445                            }
446                        }
447                    }
448                }
449            }
450        }
451    }
452
453    // Apply builtin preset defaults after optional `alc.toml` refinement.
454    if preset_name.is_some() {
455        overrides_source.push("builtin".to_string());
456
457        if eff_projections.is_none() {
458            // Safe default: machine-readable hub entries + lint pass, without
459            // requiring optional projection configs (context7/devin).
460            eff_projections = Some(vec!["hub".to_string(), "lint".to_string()]);
461        }
462        if eff_lint_strict.is_none() {
463            eff_lint_strict = Some(false);
464        }
465    }
466
467    // Resolve relative config_path against project root (preferred) or the
468    // hub source directory (fallback for hub-only repos without alc.toml).
469    if let Some(p) = eff_config_path.as_deref() {
470        let path = Path::new(p);
471        if !path.is_absolute() {
472            let source_base = Path::new(source_dir);
473            let candidate_source = source_base.join(path);
474            let candidate_project = resolved_root.as_deref().map(|root| root.join(path));
475
476            let chosen = if candidate_source.is_file() {
477                candidate_source
478            } else if let Some(c) = candidate_project {
479                if c.is_file() {
480                    c
481                } else {
482                    candidate_source
483                }
484            } else {
485                candidate_source
486            };
487
488            eff_config_path = Some(chosen.to_string_lossy().to_string());
489        }
490    }
491
492    Ok(HubDistPresetResolution {
493        projections: eff_projections,
494        config_path: eff_config_path,
495        lint_strict: eff_lint_strict,
496        catalog_version: PRESET_CATALOG_VERSION.to_string(),
497        preset_name: preset_name.map(|s| s.to_string()),
498        overrides_source,
499        resolved_project_root: resolved_root,
500    })
501}
502
503pub fn preset_meta_value(resolution: &HubDistPresetResolution) -> serde_json::Value {
504    json!({
505        "name": resolution.preset_name.as_deref(),
506        "catalog_version": resolution.catalog_version,
507        "resolved": {
508            "projections": resolution.projections,
509            "config_path": resolution.config_path,
510            "lint_strict": resolution.lint_strict,
511            "project_root": resolution.resolved_project_root.as_ref().map(|p| p.display().to_string()),
512            "overrides_source": resolution.overrides_source,
513            "preset_ref": serde_json::Value::Null,
514        }
515    })
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    // ── Existing preset tests (unchanged) ─────────────────────────────
523
524    #[test]
525    fn publish_defaults_to_hub_and_lint_when_projections_omitted() {
526        let tmp = tempfile::tempdir().expect("tempdir");
527        let root = tmp.path();
528
529        // Minimal alc.toml so resolve_project_root can find a root even when
530        // we pass an explicit project_root below.
531        std::fs::write(root.join("alc.toml"), "[packages]\n").expect("write alc.toml");
532
533        let source_dir = root.join("src");
534        std::fs::create_dir_all(&source_dir).expect("mkdir");
535
536        let res = resolve_hub_dist_preset(
537            Some("publish"),
538            Some(root.to_str().unwrap()),
539            source_dir.to_str().unwrap(),
540            None,
541            None,
542            None,
543        )
544        .expect("resolve");
545
546        assert_eq!(
547            res.projections,
548            Some(vec!["hub".to_string(), "lint".to_string()])
549        );
550        assert_eq!(res.lint_strict, Some(false));
551        assert!(res.config_path.is_none());
552    }
553
554    #[test]
555    fn alc_toml_preset_section_overrides_projections() {
556        let tmp = tempfile::tempdir().expect("tempdir");
557        let root = tmp.path();
558
559        std::fs::write(
560            root.join("alc.toml"),
561            r#"[packages]
562
563[hub.dist]
564
565[hub.dist.presets.publish]
566projections = ["context7"]
567config_path = "configs.toml"
568"#,
569        )
570        .expect("write alc.toml");
571
572        let source_dir = root.join("hub");
573        std::fs::create_dir_all(&source_dir).expect("mkdir");
574        std::fs::write(
575            root.join("configs.toml"),
576            "[context7]\nprojectTitle=\"x\"\nrules=[]\n",
577        )
578        .expect("write configs");
579
580        let res = resolve_hub_dist_preset(
581            Some("publish"),
582            Some(root.to_str().unwrap()),
583            source_dir.to_str().unwrap(),
584            None,
585            None,
586            None,
587        )
588        .expect("resolve");
589
590        assert_eq!(res.projections, Some(vec!["context7".to_string()]));
591        assert_eq!(
592            res.config_path.as_deref(),
593            Some(root.join("configs.toml").to_str().unwrap())
594        );
595    }
596
597    // ── New projection config tests ───────────────────────────────────
598
599    #[test]
600    fn load_projection_config_default_only() {
601        // No project root → templates-only config.
602        let cfg = load_hub_projection_config(None).expect("load");
603
604        assert_eq!(cfg.context7.name, templates::DEFAULT_NAME_FALLBACK);
605        assert_eq!(cfg.context7.description, templates::DEFAULT_C7_DESCRIPTION);
606        assert_eq!(
607            cfg.context7.rules,
608            templates::DEFAULT_C7_RULES
609                .iter()
610                .map(|s| s.to_string())
611                .collect::<Vec<_>>()
612        );
613
614        assert_eq!(
615            cfg.devin.repo_notes,
616            templates::DEFAULT_DEVIN_REPO_NOTES
617                .iter()
618                .map(|s| s.to_string())
619                .collect::<Vec<_>>()
620        );
621    }
622
623    #[test]
624    fn load_projection_config_name_only_override() {
625        let tmp = tempfile::tempdir().expect("tempdir");
626        let root = tmp.path();
627
628        std::fs::write(
629            root.join("alc.toml"),
630            r#"[hub]
631name = "my-project"
632"#,
633        )
634        .expect("write alc.toml");
635
636        let cfg = load_hub_projection_config(Some(root)).expect("load");
637
638        // [hub].name propagates to c7.
639        assert_eq!(cfg.context7.name, "my-project");
640        // Descriptions fall back to defaults.
641        assert_eq!(cfg.context7.description, templates::DEFAULT_C7_DESCRIPTION);
642    }
643
644    #[test]
645    fn load_projection_config_extra_rules_append() {
646        let tmp = tempfile::tempdir().expect("tempdir");
647        let root = tmp.path();
648
649        std::fs::write(
650            root.join("alc.toml"),
651            r#"[hub.context7]
652extra_rules = ["Custom rule A", "Custom rule B"]
653"#,
654        )
655        .expect("write alc.toml");
656
657        let cfg = load_hub_projection_config(Some(root)).expect("load");
658
659        let mut expected: Vec<String> = templates::DEFAULT_C7_RULES
660            .iter()
661            .map(|s| s.to_string())
662            .collect();
663        expected.push("Custom rule A".to_string());
664        expected.push("Custom rule B".to_string());
665
666        assert_eq!(cfg.context7.rules, expected);
667    }
668
669    #[test]
670    fn load_projection_config_rules_override_replaces() {
671        let tmp = tempfile::tempdir().expect("tempdir");
672        let root = tmp.path();
673
674        std::fs::write(
675            root.join("alc.toml"),
676            r#"[hub.context7]
677rules_override = ["Only this rule"]
678"#,
679        )
680        .expect("write alc.toml");
681
682        let cfg = load_hub_projection_config(Some(root)).expect("load");
683
684        assert_eq!(cfg.context7.rules, vec!["Only this rule".to_string()]);
685    }
686
687    #[test]
688    fn load_projection_config_rules_file_reads() {
689        let tmp = tempfile::tempdir().expect("tempdir");
690        let root = tmp.path();
691
692        // Write a rules file with blanks and a comment.
693        std::fs::write(
694            root.join("my_rules.txt"),
695            "Rule one\n# ignored comment\n\nRule two\n",
696        )
697        .expect("write rules file");
698
699        std::fs::write(
700            root.join("alc.toml"),
701            r#"[hub.context7]
702rules_file = "my_rules.txt"
703"#,
704        )
705        .expect("write alc.toml");
706
707        let cfg = load_hub_projection_config(Some(root)).expect("load");
708
709        assert_eq!(
710            cfg.context7.rules,
711            vec!["Rule one".to_string(), "Rule two".to_string()]
712        );
713    }
714
715    #[test]
716    fn load_projection_config_mutually_exclusive_error() {
717        let tmp = tempfile::tempdir().expect("tempdir");
718        let root = tmp.path();
719
720        std::fs::write(root.join("rules.txt"), "Rule\n").expect("write rules file");
721
722        std::fs::write(
723            root.join("alc.toml"),
724            r#"[hub.context7]
725rules_file = "rules.txt"
726rules_override = ["Also a rule"]
727"#,
728        )
729        .expect("write alc.toml");
730
731        let err = load_hub_projection_config(Some(root)).unwrap_err();
732        assert!(
733            err.contains("mutually exclusive"),
734            "expected mutually-exclusive error, got: {err}"
735        );
736    }
737
738    #[test]
739    fn load_projection_config_devin_equivalent() {
740        let tmp = tempfile::tempdir().expect("tempdir");
741        let root = tmp.path();
742
743        // Test extra_repo_notes path.
744        std::fs::write(
745            root.join("alc.toml"),
746            r#"[hub.devin]
747extra_repo_notes = ["Extra note"]
748"#,
749        )
750        .expect("write alc.toml");
751
752        let cfg = load_hub_projection_config(Some(root)).expect("load extra");
753        let mut expected: Vec<String> = templates::DEFAULT_DEVIN_REPO_NOTES
754            .iter()
755            .map(|s| s.to_string())
756            .collect();
757        expected.push("Extra note".to_string());
758        assert_eq!(cfg.devin.repo_notes, expected);
759
760        // Test repo_notes_override path.
761        let tmp2 = tempfile::tempdir().expect("tempdir2");
762        let root2 = tmp2.path();
763        std::fs::write(
764            root2.join("alc.toml"),
765            r#"[hub.devin]
766repo_notes_override = ["Only note"]
767"#,
768        )
769        .expect("write alc.toml");
770
771        let cfg2 = load_hub_projection_config(Some(root2)).expect("load override");
772        assert_eq!(cfg2.devin.repo_notes, vec!["Only note".to_string()]);
773
774        // Test repo_notes_file path.
775        let tmp3 = tempfile::tempdir().expect("tempdir3");
776        let root3 = tmp3.path();
777        std::fs::write(root3.join("notes.txt"), "Note A\nNote B\n").expect("write notes");
778        std::fs::write(
779            root3.join("alc.toml"),
780            r#"[hub.devin]
781repo_notes_file = "notes.txt"
782"#,
783        )
784        .expect("write alc.toml");
785
786        let cfg3 = load_hub_projection_config(Some(root3)).expect("load file");
787        assert_eq!(
788            cfg3.devin.repo_notes,
789            vec!["Note A".to_string(), "Note B".to_string()]
790        );
791
792        // Test mutually-exclusive error for devin.
793        let tmp4 = tempfile::tempdir().expect("tempdir4");
794        let root4 = tmp4.path();
795        std::fs::write(root4.join("notes.txt"), "Note\n").expect("write notes");
796        std::fs::write(
797            root4.join("alc.toml"),
798            r#"[hub.devin]
799repo_notes_file = "notes.txt"
800repo_notes_override = ["conflict"]
801"#,
802        )
803        .expect("write alc.toml");
804
805        let err = load_hub_projection_config(Some(root4)).unwrap_err();
806        assert!(
807            err.contains("mutually exclusive"),
808            "expected devin mutually-exclusive error, got: {err}"
809        );
810    }
811
812    #[test]
813    fn hub_section_backward_compat_dist_only() {
814        let tmp = tempfile::tempdir().expect("tempdir");
815        let root = tmp.path();
816
817        // alc.toml with only [hub.dist] — new fields absent.
818        std::fs::write(
819            root.join("alc.toml"),
820            r#"[packages]
821
822[hub.dist]
823
824[hub.dist.presets.publish]
825projections = ["hub", "lint"]
826"#,
827        )
828        .expect("write alc.toml");
829
830        let source_dir = root.join("hub");
831        std::fs::create_dir_all(&source_dir).expect("mkdir");
832
833        // resolve_hub_dist_preset must still work correctly.
834        let res = resolve_hub_dist_preset(
835            Some("publish"),
836            Some(root.to_str().unwrap()),
837            source_dir.to_str().unwrap(),
838            None,
839            None,
840            None,
841        )
842        .expect("resolve");
843
844        assert_eq!(
845            res.projections,
846            Some(vec!["hub".to_string(), "lint".to_string()])
847        );
848
849        // load_hub_projection_config also works: no context7/devin sections
850        // → falls back to defaults.
851        let cfg = load_hub_projection_config(Some(root)).expect("load projection");
852        assert_eq!(cfg.context7.name, templates::DEFAULT_NAME_FALLBACK);
853    }
854
855    #[test]
856    fn to_devin_toml_wraps_repo_notes_as_content_table() {
857        let resolved = ResolvedDevin {
858            repo_notes: vec!["a".to_string(), "b".to_string()],
859        };
860        let cfg = HubProjectionConfig {
861            context7: ResolvedContext7 {
862                name: "test".to_string(),
863                description: "desc".to_string(),
864                rules: vec![],
865            },
866            devin: resolved,
867        };
868
869        let val = cfg.to_devin_toml();
870        let table = match &val {
871            toml::Value::Table(t) => t,
872            _ => panic!("expected Table"),
873        };
874
875        let repo_notes = match table.get("repo_notes") {
876            Some(toml::Value::Array(arr)) => arr,
877            _ => panic!("expected repo_notes array"),
878        };
879
880        assert_eq!(repo_notes.len(), 2);
881
882        for (item, expected_content) in repo_notes.iter().zip(["a", "b"].iter()) {
883            match item {
884                toml::Value::Table(t) => {
885                    let content = t.get("content").expect("missing content key");
886                    assert_eq!(
887                        content,
888                        &toml::Value::String(expected_content.to_string()),
889                        "content mismatch for note"
890                    );
891                    // Must not have extra keys beyond content (no author).
892                    assert_eq!(t.len(), 1, "unexpected extra keys in note table");
893                }
894                _ => panic!("expected each repo_note to be a Table, got: {item:?}"),
895            }
896        }
897    }
898
899    #[test]
900    fn to_context7_toml_wires_project_title_from_hub_name() {
901        let cfg = HubProjectionConfig {
902            context7: ResolvedContext7 {
903                name: "my-project".to_string(),
904                description: "A description".to_string(),
905                rules: vec!["Rule 1".to_string()],
906            },
907            devin: ResolvedDevin { repo_notes: vec![] },
908        };
909
910        let val = cfg.to_context7_toml();
911        let table = match &val {
912            toml::Value::Table(t) => t,
913            _ => panic!("expected Table"),
914        };
915
916        // Key must be "projectTitle", not "name".
917        assert!(
918            table.get("name").is_none(),
919            "unexpected 'name' key in context7 output"
920        );
921        assert_eq!(
922            table.get("projectTitle"),
923            Some(&toml::Value::String("my-project".to_string())),
924            "expected projectTitle = 'my-project'"
925        );
926        assert_eq!(
927            table.get("description"),
928            Some(&toml::Value::String("A description".to_string())),
929            "expected description to be present"
930        );
931    }
932
933    #[test]
934    fn to_context7_toml_uses_default_name_fallback_when_no_name_configured() {
935        // No alc.toml → load_hub_projection_config uses DEFAULT_NAME_FALLBACK.
936        let cfg = load_hub_projection_config(None).expect("load");
937
938        let val = cfg.to_context7_toml();
939        let table = match &val {
940            toml::Value::Table(t) => t,
941            _ => panic!("expected Table"),
942        };
943
944        assert_eq!(
945            table.get("projectTitle"),
946            Some(&toml::Value::String(
947                templates::DEFAULT_NAME_FALLBACK.to_string()
948            )),
949            "expected DEFAULT_NAME_FALLBACK as projectTitle when no name configured"
950        );
951    }
952
953    #[test]
954    fn to_context7_toml_propagates_hub_name_via_load() {
955        let tmp = tempfile::tempdir().expect("tempdir");
956        let root = tmp.path();
957
958        std::fs::write(
959            root.join("alc.toml"),
960            r#"[hub]
961name = "test-hub"
962"#,
963        )
964        .expect("write alc.toml");
965
966        let cfg = load_hub_projection_config(Some(root)).expect("load");
967
968        let val = cfg.to_context7_toml();
969        let table = match &val {
970            toml::Value::Table(t) => t,
971            _ => panic!("expected Table"),
972        };
973
974        assert_eq!(
975            table.get("projectTitle"),
976            Some(&toml::Value::String("test-hub".to_string())),
977            "expected [hub].name to propagate to projectTitle"
978        );
979    }
980}