1use 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
18pub 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#[derive(Debug, Deserialize)]
37struct HubDistToml {
38 hub: Option<HubSection>,
39}
40
41#[derive(Debug, Deserialize, Default)]
47struct HubSection {
48 dist: Option<HubDistSection>,
49 #[serde(default)]
52 name: Option<String>,
53 #[serde(default)]
56 description: Option<String>,
57 #[serde(default)]
59 context7: Option<HubContext7Config>,
60 #[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#[derive(Debug, Deserialize, Default, Clone)]
80pub struct HubContext7Config {
81 #[serde(default)]
83 pub name: Option<String>,
84 #[serde(default)]
86 pub description: Option<String>,
87 #[serde(default)]
90 pub rules_override: Option<Vec<String>>,
91 #[serde(default)]
95 pub rules_file: Option<String>,
96 #[serde(default)]
98 pub extra_rules: Option<Vec<String>>,
99}
100
101#[derive(Debug, Deserialize, Default, Clone)]
109pub struct HubDevinConfig {
110 #[serde(default)]
113 pub repo_notes_override: Option<Vec<String>>,
114 #[serde(default)]
118 pub repo_notes_file: Option<String>,
119 #[serde(default)]
121 pub extra_repo_notes: Option<Vec<String>>,
122}
123
124#[derive(Debug, Clone)]
128pub struct ResolvedContext7 {
129 pub name: String,
131 pub description: String,
133 pub rules: Vec<String>,
135}
136
137#[derive(Debug, Clone)]
139pub struct ResolvedDevin {
140 pub repo_notes: Vec<String>,
148}
149
150#[derive(Debug, Clone)]
153pub struct HubProjectionConfig {
154 pub context7: ResolvedContext7,
155 pub devin: ResolvedDevin,
156}
157
158impl HubProjectionConfig {
159 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 pub fn to_devin_toml(&self) -> toml::Value {
199 let mut map = toml::value::Table::new();
200 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
216pub fn load_hub_projection_config(
240 project_root: Option<&Path>,
241) -> Result<HubProjectionConfig, String> {
242 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 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 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 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
321fn 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 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 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
369pub 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 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 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 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 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 if preset_name.is_some() {
455 overrides_source.push("builtin".to_string());
456
457 if eff_projections.is_none() {
458 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 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 #[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 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 #[test]
600 fn load_projection_config_default_only() {
601 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 assert_eq!(cfg.context7.name, "my-project");
640 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 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 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 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 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 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 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 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 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 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 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 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}