1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use git2::{FetchOptions, RemoteCallbacks, Repository};
10use semver::{Version, VersionReq};
11
12use crate::config::{
13 ConfigSourceDocument, OriginSpec, OriginType, ProfileDocument, SourceSpec, parse_config_source,
14};
15use crate::errors::{Result, SourceError};
16use crate::output::Printer;
17
18const SOURCE_MANIFEST_FILE: &str = "cfgd-source.yaml";
19const PROFILES_DIR: &str = "profiles";
20
21#[derive(Debug, Clone)]
23pub struct CachedSource {
24 pub name: String,
25 pub origin_url: String,
26 pub origin_branch: String,
27 pub local_path: PathBuf,
28 pub manifest: ConfigSourceDocument,
29 pub last_commit: Option<String>,
30 pub last_fetched: Option<String>,
31}
32
33pub struct SourceManager {
35 cache_dir: PathBuf,
36 sources: HashMap<String, CachedSource>,
37 allow_unsigned: bool,
39}
40
41impl SourceManager {
42 pub fn new(cache_dir: &Path) -> Self {
44 Self {
45 cache_dir: cache_dir.to_path_buf(),
46 sources: HashMap::new(),
47 allow_unsigned: false,
48 }
49 }
50
51 pub fn set_allow_unsigned(&mut self, allow: bool) {
53 self.allow_unsigned = allow;
54 }
55
56 pub fn default_cache_dir() -> Result<PathBuf> {
58 let base = directories::BaseDirs::new().ok_or_else(|| SourceError::CacheError {
59 message: "cannot determine home directory".into(),
60 })?;
61 Ok(base.data_local_dir().join("cfgd").join("sources"))
62 }
63
64 pub fn load_sources(&mut self, sources: &[SourceSpec], printer: &Printer) -> Result<()> {
67 let mut loaded = 0;
68 for spec in sources {
69 match self.load_source(spec, printer) {
70 Ok(()) => loaded += 1,
71 Err(e) => {
72 printer.warning(&format!("Failed to load source '{}': {}", spec.name, e));
73 }
74 }
75 }
76 if !sources.is_empty() && loaded == 0 {
77 return Err(SourceError::GitError {
78 name: "all".to_string(),
79 message: "all sources failed to load".to_string(),
80 }
81 .into());
82 }
83 Ok(())
84 }
85
86 pub fn load_source(&mut self, spec: &SourceSpec, printer: &Printer) -> Result<()> {
88 crate::validate_no_traversal(std::path::Path::new(&spec.name)).map_err(|e| {
89 SourceError::GitError {
90 name: spec.name.clone(),
91 message: format!("invalid source name: {e}"),
92 }
93 })?;
94
95 let url_lower = spec.origin.url.to_lowercase();
98 let allow_local = std::env::var("CFGD_ALLOW_LOCAL_SOURCES").is_ok();
99 if !allow_local && (url_lower.starts_with("file://") || url_lower.starts_with('/')) {
100 return Err(SourceError::GitError {
101 name: spec.name.clone(),
102 message: "local file:// URLs and absolute paths are not allowed as source origins"
103 .to_string(),
104 }
105 .into());
106 }
107
108 let source_dir = self.cache_dir.join(&spec.name);
109
110 if source_dir.exists() {
111 self.fetch_source(spec, &source_dir, printer)?;
112 } else {
113 self.clone_source(spec, &source_dir, printer)?;
114 }
115
116 let manifest = self.parse_manifest(&spec.name, &source_dir)?;
117
118 self.verify_commit_signature(&spec.name, &source_dir, &manifest.spec.policy.constraints)?;
120
121 if let Some(ref pin) = spec.sync.pin_version {
123 self.check_version_pin(&spec.name, &manifest, pin)?;
124 }
125
126 let last_commit = Self::head_commit(&source_dir);
127
128 let cached = CachedSource {
129 name: spec.name.clone(),
130 origin_url: spec.origin.url.clone(),
131 origin_branch: spec.origin.branch.clone(),
132 local_path: source_dir,
133 manifest,
134 last_commit,
135 last_fetched: Some(crate::utc_now_iso8601()),
136 };
137
138 self.sources.insert(spec.name.clone(), cached);
139 Ok(())
140 }
141
142 fn fetch_source(&self, spec: &SourceSpec, source_dir: &Path, printer: &Printer) -> Result<()> {
144 let mut cmd = crate::git_cmd_safe(
146 Some(&spec.origin.url),
147 Some(spec.origin.ssh_strict_host_key_checking),
148 );
149 cmd.args([
150 "-C",
151 &source_dir.display().to_string(),
152 "fetch",
153 "origin",
154 &spec.origin.branch,
155 ]);
156 cmd.stdout(std::process::Stdio::piped());
158 cmd.stderr(std::process::Stdio::piped());
159
160 let label = format!("Fetching source '{}'", spec.name);
161 let cli_result = printer.run_with_output(&mut cmd, &label);
162 let cli_ok = matches!(&cli_result, Ok(output) if output.status.success());
163
164 if !cli_ok {
165 let spinner = printer.spinner(&format!("Fetching source '{}' (libgit2)...", spec.name));
167
168 let repo = Repository::open(source_dir).map_err(|e| SourceError::GitError {
169 name: spec.name.clone(),
170 message: e.to_string(),
171 })?;
172
173 let mut remote = repo
174 .find_remote("origin")
175 .map_err(|e| SourceError::GitError {
176 name: spec.name.clone(),
177 message: e.to_string(),
178 })?;
179
180 let mut fo = FetchOptions::new();
181 let mut callbacks = RemoteCallbacks::new();
182 callbacks.credentials(crate::git_ssh_credentials);
183 fo.remote_callbacks(callbacks);
184
185 let fetch_result = remote
186 .fetch(&[&spec.origin.branch], Some(&mut fo), None)
187 .map_err(|e| SourceError::FetchFailed {
188 name: spec.name.clone(),
189 message: e.to_string(),
190 });
191
192 spinner.finish_and_clear();
193 fetch_result?;
194 }
195
196 let repo = Repository::open(source_dir).map_err(|e| SourceError::GitError {
198 name: spec.name.clone(),
199 message: e.to_string(),
200 })?;
201
202 let fetch_head = repo
203 .find_reference("FETCH_HEAD")
204 .map_err(|e| SourceError::GitError {
205 name: spec.name.clone(),
206 message: e.to_string(),
207 })?;
208 let fetch_commit = repo
209 .reference_to_annotated_commit(&fetch_head)
210 .map_err(|e| SourceError::GitError {
211 name: spec.name.clone(),
212 message: e.to_string(),
213 })?;
214
215 let (analysis, _) =
216 repo.merge_analysis(&[&fetch_commit])
217 .map_err(|e| SourceError::GitError {
218 name: spec.name.clone(),
219 message: e.to_string(),
220 })?;
221
222 if analysis.is_fast_forward() {
223 let refname = format!("refs/heads/{}", spec.origin.branch);
224 if let Ok(mut reference) = repo.find_reference(&refname) {
225 reference
226 .set_target(fetch_commit.id(), "cfgd source fetch")
227 .map_err(|e| SourceError::GitError {
228 name: spec.name.clone(),
229 message: e.to_string(),
230 })?;
231 }
232 repo.set_head(&refname).map_err(|e| SourceError::GitError {
233 name: spec.name.clone(),
234 message: e.to_string(),
235 })?;
236 repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
237 .map_err(|e| SourceError::GitError {
238 name: spec.name.clone(),
239 message: e.to_string(),
240 })?;
241 }
242
243 Ok(())
244 }
245
246 fn clone_source(&self, spec: &SourceSpec, source_dir: &Path, printer: &Printer) -> Result<()> {
249 if let Some(parent) = source_dir.parent() {
251 std::fs::create_dir_all(parent).map_err(|e| SourceError::CacheError {
252 message: format!("cannot create cache dir: {}", e),
253 })?;
254 }
255
256 let mut cmd = crate::git_cmd_safe(
261 Some(&spec.origin.url),
262 Some(spec.origin.ssh_strict_host_key_checking),
263 );
264 cmd.args([
265 "clone",
266 "--depth=1",
267 "--single-branch",
268 "--no-recurse-submodules",
269 "--branch",
270 &spec.origin.branch,
271 &spec.origin.url,
272 &source_dir.display().to_string(),
273 ]);
274 cmd.stdout(std::process::Stdio::piped());
275 cmd.stderr(std::process::Stdio::piped());
276
277 let label = format!("Cloning source '{}'", spec.name);
278 let cli_result = printer.run_with_output(&mut cmd, &label);
279 if matches!(&cli_result, Ok(output) if output.status.success()) {
280 let _ = crate::set_file_permissions(source_dir, 0o700);
282 return Ok(());
283 }
284
285 let _ = std::fs::remove_dir_all(source_dir);
287
288 let spinner = printer.spinner(&format!("Cloning source '{}' (libgit2)...", spec.name));
290
291 let mut fo = FetchOptions::new();
292 if spec.origin.url.starts_with("git@") || spec.origin.url.starts_with("ssh://") {
293 let mut callbacks = RemoteCallbacks::new();
294 callbacks.credentials(crate::git_ssh_credentials);
295 fo.remote_callbacks(callbacks);
296 }
297
298 fo.depth(1);
300 let mut builder = git2::build::RepoBuilder::new();
301 builder.fetch_options(fo);
302 builder.branch(&spec.origin.branch);
303
304 let clone_result =
305 builder
306 .clone(&spec.origin.url, source_dir)
307 .map_err(|e| SourceError::FetchFailed {
308 name: spec.name.clone(),
309 message: e.to_string(),
310 });
311
312 spinner.finish_and_clear();
313 clone_result?;
314
315 let _ = crate::set_file_permissions(source_dir, 0o700);
317
318 Ok(())
319 }
320
321 pub fn parse_manifest(&self, name: &str, source_dir: &Path) -> Result<ConfigSourceDocument> {
323 read_manifest(name, source_dir)
324 }
325
326 pub fn verify_commit_signature(
330 &self,
331 name: &str,
332 source_dir: &Path,
333 constraints: &crate::config::SourceConstraints,
334 ) -> Result<()> {
335 if !constraints.require_signed_commits {
336 return Ok(());
337 }
338
339 if self.allow_unsigned {
340 tracing::info!(
341 source = %name,
342 "Signature verification skipped for source '{}' (allow-unsigned is set)",
343 name
344 );
345 return Ok(());
346 }
347
348 verify_head_signature(name, source_dir)
349 }
350
351 fn check_version_pin(
353 &self,
354 name: &str,
355 manifest: &ConfigSourceDocument,
356 pin: &str,
357 ) -> Result<()> {
358 let version_str = manifest.metadata.version.as_deref().unwrap_or("0.0.0");
359
360 let version = Version::parse(version_str).map_err(|e| SourceError::InvalidManifest {
361 name: name.to_string(),
362 message: format!("invalid semver '{}': {}", version_str, e),
363 })?;
364
365 let normalized_pin = normalize_semver_pin(pin);
367 let req = VersionReq::parse(&normalized_pin).map_err(|_| SourceError::VersionMismatch {
368 name: name.to_string(),
369 version: version_str.to_string(),
370 pin: pin.to_string(),
371 })?;
372
373 if !req.matches(&version) {
374 return Err(SourceError::VersionMismatch {
375 name: name.to_string(),
376 version: version_str.to_string(),
377 pin: pin.to_string(),
378 }
379 .into());
380 }
381
382 Ok(())
383 }
384
385 fn head_commit(source_dir: &Path) -> Option<String> {
387 let repo = Repository::open(source_dir).ok()?;
388 let head = repo.head().ok()?;
389 head.target().map(|oid| oid.to_string())
390 }
391
392 pub fn get(&self, name: &str) -> Option<&CachedSource> {
394 self.sources.get(name)
395 }
396
397 pub fn all_sources(&self) -> &HashMap<String, CachedSource> {
399 &self.sources
400 }
401
402 pub fn load_source_profile(
404 &self,
405 source_name: &str,
406 profile_name: &str,
407 ) -> Result<ProfileDocument> {
408 let cached = self
409 .sources
410 .get(source_name)
411 .ok_or_else(|| SourceError::NotFound {
412 name: source_name.to_string(),
413 })?;
414
415 let profile_path = cached
416 .local_path
417 .join(PROFILES_DIR)
418 .join(format!("{}.yaml", profile_name));
419
420 if !profile_path.exists() {
421 return Err(SourceError::ProfileNotFound {
422 name: source_name.to_string(),
423 profile: profile_name.to_string(),
424 }
425 .into());
426 }
427
428 crate::config::load_profile(&profile_path)
429 }
430
431 pub fn source_profiles_dir(&self, source_name: &str) -> Result<PathBuf> {
433 let cached = self
434 .sources
435 .get(source_name)
436 .ok_or_else(|| SourceError::NotFound {
437 name: source_name.to_string(),
438 })?;
439 Ok(cached.local_path.join(PROFILES_DIR))
440 }
441
442 pub fn source_files_dir(&self, source_name: &str) -> Result<PathBuf> {
444 let cached = self
445 .sources
446 .get(source_name)
447 .ok_or_else(|| SourceError::NotFound {
448 name: source_name.to_string(),
449 })?;
450 Ok(cached.local_path.join("files"))
451 }
452
453 pub fn remove_source(&mut self, name: &str) -> Result<()> {
455 let cached = self
456 .sources
457 .remove(name)
458 .ok_or_else(|| SourceError::NotFound {
459 name: name.to_string(),
460 })?;
461
462 if cached.local_path.exists() {
463 std::fs::remove_dir_all(&cached.local_path).map_err(|e| SourceError::CacheError {
464 message: format!("failed to remove cache for '{}': {}", name, e),
465 })?;
466 }
467
468 Ok(())
469 }
470
471 pub fn build_source_spec(name: &str, url: &str, profile: Option<&str>) -> SourceSpec {
473 SourceSpec {
474 name: name.to_string(),
475 origin: OriginSpec {
476 origin_type: OriginType::Git,
477 url: url.to_string(),
478 branch: "master".to_string(),
479 auth: None,
480 ssh_strict_host_key_checking: Default::default(),
481 },
482 subscription: crate::config::SubscriptionSpec {
483 profile: profile.map(|s| s.to_string()),
484 ..Default::default()
485 },
486 sync: Default::default(),
487 }
488 }
489}
490
491fn read_manifest(name: &str, source_dir: &Path) -> Result<ConfigSourceDocument> {
493 let manifest_path = source_dir.join(SOURCE_MANIFEST_FILE);
494 if !manifest_path.exists() {
495 return Err(SourceError::InvalidManifest {
496 name: name.to_string(),
497 message: format!("{} not found", SOURCE_MANIFEST_FILE),
498 }
499 .into());
500 }
501
502 let contents =
503 std::fs::read_to_string(&manifest_path).map_err(|e| SourceError::InvalidManifest {
504 name: name.to_string(),
505 message: e.to_string(),
506 })?;
507
508 let doc = parse_config_source(&contents).map_err(|e| SourceError::InvalidManifest {
509 name: name.to_string(),
510 message: e.to_string(),
511 })?;
512
513 if doc.spec.provides.profiles.is_empty() && doc.spec.provides.profile_details.is_empty() {
514 return Err(SourceError::NoProfiles {
515 name: name.to_string(),
516 }
517 .into());
518 }
519
520 Ok(doc)
521}
522
523pub fn detect_source_manifest(dir: &Path) -> Result<Option<ConfigSourceDocument>> {
527 let manifest_path = dir.join(SOURCE_MANIFEST_FILE);
528 if !manifest_path.exists() {
529 return Ok(None);
530 }
531 let name = dir
532 .file_name()
533 .and_then(|n| n.to_str())
534 .unwrap_or("unknown");
535 read_manifest(name, dir).map(Some)
536}
537
538pub fn verify_head_signature(name: &str, repo_dir: &Path) -> Result<()> {
543 if !crate::command_available("git") {
544 return Err(SourceError::SignatureVerificationFailed {
545 name: name.to_string(),
546 message: "git CLI is required for signature verification but is not available on PATH"
547 .into(),
548 }
549 .into());
550 }
551
552 let output = crate::command_output_with_timeout(
553 std::process::Command::new("git")
554 .args([
555 "-C",
556 &repo_dir.display().to_string(),
557 "log",
558 "--format=%G?",
559 "-1",
560 ])
561 .stdout(std::process::Stdio::piped())
562 .stderr(std::process::Stdio::piped()),
563 crate::COMMAND_TIMEOUT,
564 )
565 .map_err(|e| SourceError::SignatureVerificationFailed {
566 name: name.to_string(),
567 message: format!("failed to run git: {}", e),
568 })?;
569
570 if !output.status.success() {
571 return Err(SourceError::SignatureVerificationFailed {
572 name: name.to_string(),
573 message: format!(
574 "git log failed (exit {}): {}",
575 output.status.code().unwrap_or(-1),
576 crate::stderr_lossy_trimmed(&output)
577 ),
578 }
579 .into());
580 }
581
582 let status = crate::stdout_lossy_trimmed(&output);
583
584 match status.as_str() {
585 "G" | "U" => {
587 tracing::info!(
588 source = %name,
589 "Source '{}' HEAD commit signature verified (status: {})",
590 name, status
591 );
592 Ok(())
593 }
594 "N" => Err(SourceError::SignatureVerificationFailed {
595 name: name.to_string(),
596 message: "HEAD commit is not signed — source requires signed commits".into(),
597 }
598 .into()),
599 "B" => Err(SourceError::SignatureVerificationFailed {
600 name: name.to_string(),
601 message: "HEAD commit has a bad (invalid) signature".into(),
602 }
603 .into()),
604 "E" => Err(SourceError::SignatureVerificationFailed {
605 name: name.to_string(),
606 message: "signature cannot be checked — ensure the signing key is imported".into(),
607 }
608 .into()),
609 "X" | "Y" => Err(SourceError::SignatureVerificationFailed {
610 name: name.to_string(),
611 message: "HEAD commit signature or signing key has expired".into(),
612 }
613 .into()),
614 "R" => Err(SourceError::SignatureVerificationFailed {
615 name: name.to_string(),
616 message: "HEAD commit was signed with a revoked key".into(),
617 }
618 .into()),
619 other => Err(SourceError::SignatureVerificationFailed {
620 name: name.to_string(),
621 message: format!("unexpected signature status '{}' from git", other),
622 }
623 .into()),
624 }
625}
626
627fn normalize_semver_pin(pin: &str) -> String {
633 let trimmed = pin.trim();
634
635 if let Some(rest) = trimmed.strip_prefix('~') {
636 let dots = rest.matches('.').count();
637 match dots {
638 0 => format!("^{}.0.0", rest),
640 1 => format!("~{}.0", rest),
642 _ => trimmed.to_string(),
643 }
644 } else if let Some(rest) = trimmed.strip_prefix('^') {
645 let dots = rest.matches('.').count();
646 match dots {
647 0 => format!("^{}.0.0", rest),
648 1 => format!("^{}.0", rest),
649 _ => trimmed.to_string(),
650 }
651 } else {
652 trimmed.to_string()
653 }
654}
655
656pub fn git_clone_with_fallback(
659 url: &str,
660 target: &Path,
661 printer: &Printer,
662) -> std::result::Result<(), String> {
663 let mut cmd = crate::git_cmd_safe(Some(url), None);
665 cmd.args([
666 "clone",
667 "--depth=1",
668 "--no-recurse-submodules",
669 url,
670 &target.display().to_string(),
671 ]);
672 cmd.stdout(std::process::Stdio::piped());
673 cmd.stderr(std::process::Stdio::piped());
674
675 let label = format!("Cloning {}", url);
676 let cli_result = printer.run_with_output(&mut cmd, &label);
677 if matches!(&cli_result, Ok(output) if output.status.success()) {
678 return Ok(());
679 }
680
681 let _ = std::fs::remove_dir_all(target);
683 let _ = std::fs::create_dir_all(target);
684
685 let spinner = printer.spinner("Cloning (libgit2)...");
687
688 let mut fetch_opts = git2::FetchOptions::new();
689 fetch_opts.depth(1);
690 if url.starts_with("git@") || url.starts_with("ssh://") {
691 let mut callbacks = git2::RemoteCallbacks::new();
692 callbacks.credentials(crate::git_ssh_credentials);
693 fetch_opts.remote_callbacks(callbacks);
694 }
695 let mut builder = git2::build::RepoBuilder::new();
696 builder.fetch_options(fetch_opts);
697
698 let result = builder
699 .clone(url, target)
700 .map(|_| ())
701 .map_err(|e| format!("Failed to clone {}: {}", url, e));
702
703 spinner.finish_and_clear();
704 result
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710 use crate::test_helpers::test_printer;
711
712 #[test]
713 fn normalize_tilde_pin() {
714 assert_eq!(normalize_semver_pin("~2"), "^2.0.0");
716 assert_eq!(normalize_semver_pin("~2.1"), "~2.1.0");
718 assert_eq!(normalize_semver_pin("~2.1.0"), "~2.1.0");
720 }
721
722 #[test]
723 fn normalize_caret_pin() {
724 assert_eq!(normalize_semver_pin("^3"), "^3.0.0");
725 assert_eq!(normalize_semver_pin("^3.1"), "^3.1.0");
726 assert_eq!(normalize_semver_pin("^3.1.2"), "^3.1.2");
727 }
728
729 #[test]
730 fn normalize_exact_pin() {
731 assert_eq!(normalize_semver_pin("=1.2.3"), "=1.2.3");
732 assert_eq!(normalize_semver_pin("1.2.3"), "1.2.3");
733 }
734
735 #[test]
736 fn version_pin_matching() {
737 let pin = normalize_semver_pin("~2");
738 let req = VersionReq::parse(&pin).unwrap();
739 assert!(req.matches(&Version::new(2, 0, 0)));
740 assert!(req.matches(&Version::new(2, 5, 0)));
741 assert!(!req.matches(&Version::new(3, 0, 0)));
742 assert!(!req.matches(&Version::new(1, 9, 0)));
743 }
744
745 #[test]
746 fn source_manager_creates_cache_dir() {
747 let dir = tempfile::tempdir().unwrap();
748 let mgr = SourceManager::new(dir.path());
749 assert!(mgr.all_sources().is_empty());
750 }
751
752 #[test]
753 fn build_source_spec_with_defaults() {
754 let spec = SourceManager::build_source_spec(
755 "acme",
756 "git@github.com:acme/config.git",
757 Some("backend"),
758 );
759 assert_eq!(spec.name, "acme");
760 assert_eq!(spec.subscription.priority, 500);
761 assert_eq!(spec.subscription.profile.as_deref(), Some("backend"));
762 assert_eq!(spec.sync.interval, "1h");
763 }
764
765 #[test]
766 fn build_source_spec_no_profile() {
767 let spec = SourceManager::build_source_spec("test", "https://example.com/config.git", None);
768 assert!(spec.subscription.profile.is_none());
769 }
770
771 #[test]
772 fn remove_nonexistent_source() {
773 let dir = tempfile::tempdir().unwrap();
774 let mut mgr = SourceManager::new(dir.path());
775 let err = mgr.remove_source("nonexistent").unwrap_err();
776 assert!(
777 err.to_string().contains("not found"),
778 "expected 'not found' error, got: {err}"
779 );
780 }
781
782 #[test]
783 fn get_nonexistent_source() {
784 let dir = tempfile::tempdir().unwrap();
785 let mgr = SourceManager::new(dir.path());
786 assert!(mgr.get("nonexistent").is_none());
787 }
788
789 #[test]
790 fn detect_source_manifest_found() {
791 let dir = tempfile::tempdir().unwrap();
792 std::fs::write(
793 dir.path().join(SOURCE_MANIFEST_FILE),
794 r#"
795apiVersion: cfgd.io/v1alpha1
796kind: ConfigSource
797metadata:
798 name: test-source
799spec:
800 provides:
801 profiles:
802 - base
803 policy: {}
804"#,
805 )
806 .unwrap();
807
808 let result = detect_source_manifest(dir.path()).unwrap();
809 assert!(result.is_some());
810 assert_eq!(result.unwrap().metadata.name, "test-source");
811 }
812
813 #[test]
814 fn detect_source_manifest_not_found() {
815 let dir = tempfile::tempdir().unwrap();
816 let result = detect_source_manifest(dir.path()).unwrap();
817 assert!(result.is_none());
818 }
819
820 #[test]
821 fn detect_source_manifest_invalid() {
822 let dir = tempfile::tempdir().unwrap();
823 std::fs::write(dir.path().join(SOURCE_MANIFEST_FILE), "not: valid: yaml: [").unwrap();
824 let err = detect_source_manifest(dir.path()).unwrap_err();
825 assert!(
826 err.to_string().contains("invalid") || err.to_string().contains("ConfigSource"),
827 "expected manifest parse error, got: {err}"
828 );
829 }
830
831 #[test]
832 fn parse_manifest_missing_file() {
833 let dir = tempfile::tempdir().unwrap();
834 let mgr = SourceManager::new(dir.path());
835 let result = mgr.parse_manifest("test", dir.path());
836 assert!(result.is_err());
837 assert!(result.unwrap_err().to_string().contains("not found"));
838 }
839
840 #[test]
841 fn parse_manifest_valid() {
842 let dir = tempfile::tempdir().unwrap();
843 std::fs::write(
844 dir.path().join(SOURCE_MANIFEST_FILE),
845 r#"
846apiVersion: cfgd.io/v1alpha1
847kind: ConfigSource
848metadata:
849 name: test-source
850 version: "1.0.0"
851spec:
852 provides:
853 profiles:
854 - base
855 policy:
856 required:
857 packages:
858 brew:
859 formulae:
860 - git-secrets
861 constraints:
862 noScripts: true
863"#,
864 )
865 .unwrap();
866
867 let mgr = SourceManager::new(dir.path());
868 let manifest = mgr.parse_manifest("test", dir.path()).unwrap();
869 assert_eq!(manifest.metadata.name, "test-source");
870 assert_eq!(manifest.spec.provides.profiles, vec!["base"]);
871 }
872
873 #[test]
874 fn check_version_pin_passes() {
875 let dir = tempfile::tempdir().unwrap();
876 let mgr = SourceManager::new(dir.path());
877
878 let manifest = ConfigSourceDocument {
879 api_version: crate::API_VERSION.into(),
880 kind: "ConfigSource".into(),
881 metadata: crate::config::ConfigSourceMetadata {
882 name: "test".into(),
883 version: Some("2.1.0".into()),
884 description: None,
885 },
886 spec: crate::config::ConfigSourceSpec {
887 provides: Default::default(),
888 policy: Default::default(),
889 },
890 };
891
892 mgr.check_version_pin("test", &manifest, "~2")
894 .expect("~2 should match 2.1.0");
895 mgr.check_version_pin("test", &manifest, "^2")
896 .expect("^2 should match 2.1.0");
897 mgr.check_version_pin("test", &manifest, "~2.1")
898 .expect("~2.1 should match 2.1.0");
899 }
900
901 #[test]
902 fn check_version_pin_fails() {
903 let dir = tempfile::tempdir().unwrap();
904 let mgr = SourceManager::new(dir.path());
905
906 let manifest = ConfigSourceDocument {
907 api_version: crate::API_VERSION.into(),
908 kind: "ConfigSource".into(),
909 metadata: crate::config::ConfigSourceMetadata {
910 name: "test".into(),
911 version: Some("3.0.0".into()),
912 description: None,
913 },
914 spec: crate::config::ConfigSourceSpec {
915 provides: Default::default(),
916 policy: Default::default(),
917 },
918 };
919
920 let err = mgr.check_version_pin("test", &manifest, "~2").unwrap_err();
921 let msg = err.to_string();
922 assert!(
923 msg.contains("3.0.0") && msg.contains("~2"),
924 "expected version mismatch with '3.0.0' and '~2', got: {msg}"
925 );
926 }
927
928 #[test]
929 fn verify_signature_skipped_when_not_required() {
930 let dir = tempfile::tempdir().unwrap();
931 let mgr = SourceManager::new(dir.path());
932 let constraints = crate::config::SourceConstraints::default();
933 assert!(
934 !constraints.require_signed_commits,
935 "default should be false"
936 );
937 let result = mgr.verify_commit_signature("test", dir.path(), &constraints);
939 assert_eq!(
940 result.unwrap(),
941 (),
942 "expected Ok(()) when signatures not required"
943 );
944 }
945
946 #[test]
947 fn verify_signature_skipped_when_allow_unsigned() {
948 let dir = tempfile::tempdir().unwrap();
949 let mut mgr = SourceManager::new(dir.path());
950 mgr.set_allow_unsigned(true);
951 let constraints = crate::config::SourceConstraints {
952 require_signed_commits: true,
953 ..Default::default()
954 };
955 assert!(mgr.allow_unsigned, "allow_unsigned should be set");
956 assert!(
957 constraints.require_signed_commits,
958 "require_signed_commits should be true"
959 );
960 let result = mgr.verify_commit_signature("test", dir.path(), &constraints);
962 assert_eq!(
963 result.unwrap(),
964 (),
965 "expected Ok(()) when allow_unsigned bypasses verification"
966 );
967 }
968
969 #[test]
970 fn verify_signature_fails_on_unsigned_commit() {
971 let dir = tempfile::tempdir().unwrap();
973 let repo = git2::Repository::init(dir.path()).unwrap();
974 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
975 let tree_id = repo.index().unwrap().write_tree().unwrap();
976 let tree = repo.find_tree(tree_id).unwrap();
977 repo.commit(Some("HEAD"), &sig, &sig, "unsigned commit", &tree, &[])
978 .unwrap();
979
980 let mgr = SourceManager::new(dir.path());
981 let constraints = crate::config::SourceConstraints {
982 require_signed_commits: true,
983 ..Default::default()
984 };
985
986 let result = mgr.verify_commit_signature("test-source", dir.path(), &constraints);
987 assert!(result.is_err());
988 let err_msg = result.unwrap_err().to_string();
989 assert!(
990 err_msg.contains("not signed"),
991 "expected 'not signed' in error, got: {}",
992 err_msg
993 );
994 }
995
996 #[test]
997 fn set_allow_unsigned_works() {
998 let dir = tempfile::tempdir().unwrap();
999 let mut mgr = SourceManager::new(dir.path());
1000 assert!(!mgr.allow_unsigned);
1001 mgr.set_allow_unsigned(true);
1002 assert!(mgr.allow_unsigned);
1003 }
1004
1005 #[test]
1006 fn verify_head_signature_fails_on_unsigned_repo() {
1007 let tmp = tempfile::tempdir().unwrap();
1009 let repo_dir = tmp.path().join("repo");
1010 std::fs::create_dir_all(&repo_dir).unwrap();
1011
1012 let repo = git2::Repository::init(&repo_dir).unwrap();
1013 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
1014
1015 std::fs::write(repo_dir.join("README"), "test\n").unwrap();
1017 let mut index = repo.index().unwrap();
1018 index.add_path(std::path::Path::new("README")).unwrap();
1019 index.write().unwrap();
1020 let tree_id = index.write_tree().unwrap();
1021 let tree = repo.find_tree(tree_id).unwrap();
1022 repo.commit(Some("HEAD"), &sig, &sig, "unsigned commit", &tree, &[])
1023 .unwrap();
1024
1025 let result = verify_head_signature("test-source", &repo_dir);
1027 assert!(result.is_err());
1028 let err_msg = result.unwrap_err().to_string();
1029 assert!(
1030 err_msg.contains("not signed") || err_msg.contains("signature"),
1031 "expected signature-related error, got: {}",
1032 err_msg
1033 );
1034 }
1035
1036 #[test]
1037 fn source_profiles_dir_nonexistent_source() {
1038 let dir = tempfile::tempdir().unwrap();
1039 let mgr = SourceManager::new(dir.path());
1040
1041 let result = mgr.source_profiles_dir("nonexistent");
1042 assert!(result.is_err());
1043 let err_msg = result.unwrap_err().to_string();
1044 assert!(
1045 err_msg.contains("not found"),
1046 "expected 'not found' error, got: {}",
1047 err_msg
1048 );
1049 }
1050
1051 #[test]
1052 fn source_files_dir_nonexistent_source() {
1053 let dir = tempfile::tempdir().unwrap();
1054 let mgr = SourceManager::new(dir.path());
1055
1056 let result = mgr.source_files_dir("nonexistent");
1057 assert!(result.is_err());
1058 let err_msg = result.unwrap_err().to_string();
1059 assert!(
1060 err_msg.contains("not found"),
1061 "expected 'not found' error, got: {}",
1062 err_msg
1063 );
1064 }
1065
1066 #[test]
1067 fn normalize_semver_pin_whitespace() {
1068 assert_eq!(normalize_semver_pin(" ~2 "), "^2.0.0");
1069 assert_eq!(normalize_semver_pin(" ^3.1 "), "^3.1.0");
1070 }
1071
1072 #[test]
1073 fn normalize_semver_pin_plain_version() {
1074 assert_eq!(normalize_semver_pin("2.1.0"), "2.1.0");
1076 assert_eq!(normalize_semver_pin(">=1.0.0"), ">=1.0.0");
1077 }
1078
1079 #[test]
1080 fn check_version_pin_no_manifest_version_uses_zero() {
1081 let dir = tempfile::tempdir().unwrap();
1082 let mgr = SourceManager::new(dir.path());
1083
1084 let manifest = ConfigSourceDocument {
1085 api_version: crate::API_VERSION.into(),
1086 kind: "ConfigSource".into(),
1087 metadata: crate::config::ConfigSourceMetadata {
1088 name: "test".into(),
1089 version: None, description: None,
1091 },
1092 spec: crate::config::ConfigSourceSpec {
1093 provides: Default::default(),
1094 policy: Default::default(),
1095 },
1096 };
1097
1098 mgr.check_version_pin("test", &manifest, "~0")
1100 .expect("~0 should match defaulted version 0.0.0");
1101 let err = mgr.check_version_pin("test", &manifest, "~1").unwrap_err();
1103 let msg = err.to_string();
1104 assert!(
1105 msg.contains("0.0.0") && msg.contains("~1"),
1106 "expected version mismatch with '0.0.0' and '~1', got: {msg}"
1107 );
1108 }
1109
1110 #[test]
1111 fn check_version_pin_invalid_semver_in_manifest() {
1112 let dir = tempfile::tempdir().unwrap();
1113 let mgr = SourceManager::new(dir.path());
1114
1115 let manifest = ConfigSourceDocument {
1116 api_version: crate::API_VERSION.into(),
1117 kind: "ConfigSource".into(),
1118 metadata: crate::config::ConfigSourceMetadata {
1119 name: "test".into(),
1120 version: Some("not-a-version".into()),
1121 description: None,
1122 },
1123 spec: crate::config::ConfigSourceSpec {
1124 provides: Default::default(),
1125 policy: Default::default(),
1126 },
1127 };
1128
1129 let result = mgr.check_version_pin("test", &manifest, "~1");
1130 assert!(result.is_err());
1131 let err = result.unwrap_err().to_string();
1132 assert!(
1133 err.contains("semver") || err.contains("invalid"),
1134 "expected semver error, got: {err}"
1135 );
1136 }
1137
1138 #[test]
1139 fn check_version_pin_invalid_pin_format() {
1140 let dir = tempfile::tempdir().unwrap();
1141 let mgr = SourceManager::new(dir.path());
1142
1143 let manifest = ConfigSourceDocument {
1144 api_version: crate::API_VERSION.into(),
1145 kind: "ConfigSource".into(),
1146 metadata: crate::config::ConfigSourceMetadata {
1147 name: "test".into(),
1148 version: Some("1.0.0".into()),
1149 description: None,
1150 },
1151 spec: crate::config::ConfigSourceSpec {
1152 provides: Default::default(),
1153 policy: Default::default(),
1154 },
1155 };
1156
1157 let err = mgr
1158 .check_version_pin("test", &manifest, "not-a-pin")
1159 .unwrap_err();
1160 let msg = err.to_string();
1161 assert!(
1162 msg.contains("not-a-pin") && msg.contains("version"),
1163 "expected version mismatch error mentioning 'not-a-pin', got: {msg}"
1164 );
1165 }
1166
1167 #[test]
1168 fn read_manifest_no_profiles_is_error() {
1169 let dir = tempfile::tempdir().unwrap();
1170 std::fs::write(
1171 dir.path().join(SOURCE_MANIFEST_FILE),
1172 r#"
1173apiVersion: cfgd.io/v1alpha1
1174kind: ConfigSource
1175metadata:
1176 name: empty-source
1177spec:
1178 provides:
1179 profiles: []
1180 policy: {}
1181"#,
1182 )
1183 .unwrap();
1184
1185 let result = read_manifest("empty-source", dir.path());
1186 assert!(result.is_err());
1187 let err = result.unwrap_err().to_string();
1188 assert!(
1189 err.contains("no profiles") || err.contains("NoProfiles"),
1190 "expected no-profiles error, got: {err}"
1191 );
1192 }
1193
1194 #[test]
1195 fn detect_source_manifest_no_profiles_is_error() {
1196 let dir = tempfile::tempdir().unwrap();
1197 std::fs::write(
1198 dir.path().join(SOURCE_MANIFEST_FILE),
1199 r#"
1200apiVersion: cfgd.io/v1alpha1
1201kind: ConfigSource
1202metadata:
1203 name: empty-profiles
1204spec:
1205 provides:
1206 profiles: []
1207 policy: {}
1208"#,
1209 )
1210 .unwrap();
1211
1212 let err = detect_source_manifest(dir.path()).unwrap_err();
1214 assert!(
1215 err.to_string().contains("no profiles") || err.to_string().contains("NoProfiles"),
1216 "expected no-profiles error, got: {err}"
1217 );
1218 }
1219
1220 #[test]
1221 fn load_source_profile_nonexistent_source() {
1222 let dir = tempfile::tempdir().unwrap();
1223 let mgr = SourceManager::new(dir.path());
1224
1225 let result = mgr.load_source_profile("nonexistent", "default");
1226 assert!(result.is_err());
1227 let err = result.unwrap_err().to_string();
1228 assert!(
1229 err.contains("not found"),
1230 "expected 'not found' error, got: {err}"
1231 );
1232 }
1233
1234 #[test]
1235 fn default_cache_dir_returns_path() {
1236 let result = SourceManager::default_cache_dir();
1239 assert!(result.is_ok());
1240 let path = result.unwrap();
1241 assert!(path.to_string_lossy().contains("cfgd"));
1242 assert!(path.to_string_lossy().contains("sources"));
1243 }
1244
1245 #[test]
1246 fn build_source_spec_defaults() {
1247 let spec = SourceManager::build_source_spec("test", "https://example.com/config.git", None);
1248 assert_eq!(spec.origin.branch, "master");
1249 assert_eq!(spec.origin.url, "https://example.com/config.git");
1250 assert!(spec.origin.auth.is_none());
1251 assert!(spec.subscription.profile.is_none());
1252 assert_eq!(spec.sync.interval, "1h");
1254 assert!(spec.sync.pin_version.is_none());
1255 }
1256
1257 #[test]
1258 fn subscription_config_from_spec() {
1259 let spec = crate::config::SourceSpec {
1260 name: "test".into(),
1261 origin: crate::config::OriginSpec {
1262 origin_type: OriginType::Git,
1263 url: "https://example.com".into(),
1264 branch: "main".into(),
1265 auth: None,
1266 ssh_strict_host_key_checking: Default::default(),
1267 },
1268 subscription: crate::config::SubscriptionSpec {
1269 profile: Some("backend".into()),
1270 priority: 500,
1271 accept_recommended: true,
1272 opt_in: vec!["extra".into()],
1273 overrides: serde_yaml::Value::Null,
1274 reject: serde_yaml::Value::Null,
1275 },
1276 sync: Default::default(),
1277 };
1278
1279 let config = crate::composition::SubscriptionConfig::from_spec(&spec);
1280 assert!(config.accept_recommended);
1281 assert_eq!(config.opt_in, vec!["extra".to_string()]);
1282 }
1283
1284 #[test]
1285 fn version_pin_tilde_two_part() {
1286 let pin = normalize_semver_pin("~2.1");
1288 let req = VersionReq::parse(&pin).unwrap();
1289 assert!(req.matches(&Version::new(2, 1, 0)));
1290 assert!(req.matches(&Version::new(2, 1, 9)));
1291 assert!(!req.matches(&Version::new(2, 2, 0)));
1292 }
1293
1294 #[test]
1295 fn version_pin_caret_matches_minor_bumps() {
1296 let pin = normalize_semver_pin("^3");
1297 let req = VersionReq::parse(&pin).unwrap();
1298 assert!(req.matches(&Version::new(3, 0, 0)));
1299 assert!(req.matches(&Version::new(3, 9, 0)));
1300 assert!(!req.matches(&Version::new(4, 0, 0)));
1301 }
1302
1303 #[test]
1304 fn load_source_rejects_traversal_name() {
1305 let dir = tempfile::tempdir().unwrap();
1306 let mut mgr = SourceManager::new(dir.path());
1307 let printer = test_printer();
1308
1309 let spec = crate::config::SourceSpec {
1310 name: "../evil".into(),
1311 origin: crate::config::OriginSpec {
1312 origin_type: OriginType::Git,
1313 url: "https://example.com/config.git".into(),
1314 branch: "main".into(),
1315 auth: None,
1316 ssh_strict_host_key_checking: Default::default(),
1317 },
1318 subscription: Default::default(),
1319 sync: Default::default(),
1320 };
1321
1322 let result = mgr.load_source(&spec, &printer);
1323 assert!(result.is_err());
1324 let err = result.unwrap_err().to_string();
1325 assert!(
1326 err.contains("invalid source name") || err.contains("traversal"),
1327 "expected traversal error, got: {err}"
1328 );
1329 }
1330
1331 #[test]
1332 fn remove_source_success() {
1333 let dir = tempfile::tempdir().unwrap();
1334 let mut mgr = SourceManager::new(dir.path());
1335
1336 let source_path = dir.path().join("test-source");
1338 std::fs::create_dir_all(&source_path).unwrap();
1339 std::fs::write(source_path.join("marker.txt"), "exists").unwrap();
1340 assert!(source_path.exists());
1341
1342 let cached = CachedSource {
1344 name: "test-source".to_string(),
1345 origin_url: "https://example.com/config.git".to_string(),
1346 origin_branch: "main".to_string(),
1347 local_path: source_path.clone(),
1348 manifest: crate::config::ConfigSourceDocument {
1349 api_version: crate::API_VERSION.into(),
1350 kind: "ConfigSource".into(),
1351 metadata: crate::config::ConfigSourceMetadata {
1352 name: "test-source".into(),
1353 version: Some("1.0.0".into()),
1354 description: None,
1355 },
1356 spec: crate::config::ConfigSourceSpec {
1357 provides: Default::default(),
1358 policy: Default::default(),
1359 },
1360 },
1361 last_commit: None,
1362 last_fetched: None,
1363 };
1364 mgr.sources.insert("test-source".to_string(), cached);
1365
1366 assert!(mgr.get("test-source").is_some());
1368
1369 mgr.remove_source("test-source")
1371 .expect("remove_source should succeed for existing cached source");
1372
1373 assert!(mgr.get("test-source").is_none());
1375 assert!(
1376 mgr.all_sources().is_empty(),
1377 "sources map should be empty after removal"
1378 );
1379
1380 assert!(!source_path.exists(), "source directory should be deleted");
1382 assert!(
1383 !source_path.join("marker.txt").exists(),
1384 "files within source directory should be deleted"
1385 );
1386 }
1387
1388 fn insert_fake_source(mgr: &mut SourceManager, name: &str, local_path: PathBuf) {
1391 let cached = CachedSource {
1392 name: name.to_string(),
1393 origin_url: "https://example.com/config.git".to_string(),
1394 origin_branch: "main".to_string(),
1395 local_path,
1396 manifest: crate::config::ConfigSourceDocument {
1397 api_version: crate::API_VERSION.into(),
1398 kind: "ConfigSource".into(),
1399 metadata: crate::config::ConfigSourceMetadata {
1400 name: name.into(),
1401 version: Some("1.0.0".into()),
1402 description: None,
1403 },
1404 spec: crate::config::ConfigSourceSpec {
1405 provides: Default::default(),
1406 policy: Default::default(),
1407 },
1408 },
1409 last_commit: None,
1410 last_fetched: None,
1411 };
1412 mgr.sources.insert(name.to_string(), cached);
1413 }
1414
1415 #[test]
1416 fn load_source_profile_success() {
1417 let dir = tempfile::tempdir().unwrap();
1418 let source_path = dir.path().join("my-source");
1419 std::fs::create_dir_all(source_path.join(PROFILES_DIR)).unwrap();
1420
1421 std::fs::write(
1423 source_path.join(PROFILES_DIR).join("default.yaml"),
1424 r#"
1425apiVersion: cfgd.io/v1alpha1
1426kind: Profile
1427metadata:
1428 name: default
1429spec:
1430 packages:
1431 pipx:
1432 - ripgrep
1433"#,
1434 )
1435 .unwrap();
1436
1437 let mut mgr = SourceManager::new(dir.path());
1438 insert_fake_source(&mut mgr, "my-source", source_path);
1439
1440 let result = mgr.load_source_profile("my-source", "default");
1441 assert!(
1442 result.is_ok(),
1443 "load_source_profile failed: {:?}",
1444 result.err()
1445 );
1446 let profile = result.unwrap();
1447 assert_eq!(profile.metadata.name, "default");
1448 }
1449
1450 #[test]
1451 fn load_source_profile_missing_profile_file() {
1452 let dir = tempfile::tempdir().unwrap();
1453 let source_path = dir.path().join("my-source");
1454 std::fs::create_dir_all(source_path.join(PROFILES_DIR)).unwrap();
1455 let mut mgr = SourceManager::new(dir.path());
1458 insert_fake_source(&mut mgr, "my-source", source_path);
1459
1460 let result = mgr.load_source_profile("my-source", "nonexistent");
1461 assert!(result.is_err());
1462 let err = result.unwrap_err().to_string();
1463 assert!(
1464 err.contains("not found") || err.contains("ProfileNotFound"),
1465 "expected profile not found error, got: {err}"
1466 );
1467 }
1468
1469 #[test]
1470 fn source_profiles_dir_returns_path_for_cached_source() {
1471 let dir = tempfile::tempdir().unwrap();
1472 let source_path = dir.path().join("src-1");
1473 std::fs::create_dir_all(&source_path).unwrap();
1474
1475 let mut mgr = SourceManager::new(dir.path());
1476 insert_fake_source(&mut mgr, "src-1", source_path.clone());
1477
1478 let result = mgr.source_profiles_dir("src-1");
1479 assert!(result.is_ok());
1480 assert_eq!(result.unwrap(), source_path.join(PROFILES_DIR));
1481 }
1482
1483 #[test]
1484 fn source_files_dir_returns_path_for_cached_source() {
1485 let dir = tempfile::tempdir().unwrap();
1486 let source_path = dir.path().join("src-1");
1487 std::fs::create_dir_all(&source_path).unwrap();
1488
1489 let mut mgr = SourceManager::new(dir.path());
1490 insert_fake_source(&mut mgr, "src-1", source_path.clone());
1491
1492 let result = mgr.source_files_dir("src-1");
1493 assert!(result.is_ok());
1494 assert_eq!(result.unwrap(), source_path.join("files"));
1495 }
1496
1497 #[test]
1498 fn head_commit_returns_oid_for_valid_repo() {
1499 let dir = tempfile::tempdir().unwrap();
1500 let repo_dir = dir.path().join("repo");
1501
1502 let repo = git2::Repository::init(&repo_dir).unwrap();
1504 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
1505 std::fs::write(repo_dir.join("file.txt"), "content\n").unwrap();
1506 let mut index = repo.index().unwrap();
1507 index.add_path(std::path::Path::new("file.txt")).unwrap();
1508 index.write().unwrap();
1509 let tree_id = index.write_tree().unwrap();
1510 let tree = repo.find_tree(tree_id).unwrap();
1511 let oid = repo
1512 .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
1513 .unwrap();
1514
1515 let result = SourceManager::head_commit(&repo_dir);
1516 assert!(result.is_some());
1517 assert_eq!(result.unwrap(), oid.to_string());
1518 }
1519
1520 #[test]
1521 fn head_commit_returns_none_for_nonexistent_dir() {
1522 let result = SourceManager::head_commit(std::path::Path::new("/tmp/no-such-repo-xyz"));
1523 assert!(result.is_none());
1524 }
1525
1526 #[test]
1527 fn load_sources_fails_when_all_sources_fail() {
1528 let dir = tempfile::tempdir().unwrap();
1529 let mut mgr = SourceManager::new(dir.path());
1530 let printer = test_printer();
1531
1532 let specs = vec![
1534 crate::config::SourceSpec {
1535 name: "bad1".into(),
1536 origin: crate::config::OriginSpec {
1537 origin_type: OriginType::Git,
1538 url: "file:///nonexistent/repo1".into(),
1539 branch: "main".into(),
1540 auth: None,
1541 ssh_strict_host_key_checking: Default::default(),
1542 },
1543 subscription: Default::default(),
1544 sync: Default::default(),
1545 },
1546 crate::config::SourceSpec {
1547 name: "bad2".into(),
1548 origin: crate::config::OriginSpec {
1549 origin_type: OriginType::Git,
1550 url: "file:///nonexistent/repo2".into(),
1551 branch: "main".into(),
1552 auth: None,
1553 ssh_strict_host_key_checking: Default::default(),
1554 },
1555 subscription: Default::default(),
1556 sync: Default::default(),
1557 },
1558 ];
1559
1560 let result = mgr.load_sources(&specs, &printer);
1561 assert!(result.is_err());
1562 let err = result.unwrap_err().to_string();
1563 assert!(
1564 err.contains("all sources failed"),
1565 "expected all sources failed error, got: {err}"
1566 );
1567 }
1568
1569 #[test]
1570 fn load_sources_succeeds_with_empty_list() {
1571 let dir = tempfile::tempdir().unwrap();
1572 let mut mgr = SourceManager::new(dir.path());
1573 let printer = test_printer();
1574
1575 mgr.load_sources(&[], &printer)
1577 .expect("load_sources with empty list should succeed");
1578 assert!(
1579 mgr.all_sources().is_empty(),
1580 "no sources should be loaded from empty list"
1581 );
1582 }
1583
1584 #[test]
1585 fn git_clone_with_fallback_local_repo() {
1586 let dir = tempfile::tempdir().unwrap();
1587 let origin_path = dir.path().join("origin");
1588
1589 let repo = git2::Repository::init(&origin_path).unwrap();
1591 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
1592 std::fs::write(origin_path.join("file.txt"), "hello").unwrap();
1596 let mut index = repo.index().unwrap();
1597 index.add_path(std::path::Path::new("file.txt")).unwrap();
1598 index.write().unwrap();
1599 let tree_id = index.write_tree().unwrap();
1600 let tree = repo.find_tree(tree_id).unwrap();
1601 repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
1602 .unwrap();
1603
1604 let clone_path = dir.path().join("clone");
1605 let printer = test_printer();
1606 git_clone_with_fallback(&origin_path.display().to_string(), &clone_path, &printer)
1607 .expect("clone of local repo should succeed");
1608
1609 assert!(
1611 clone_path.join("file.txt").exists(),
1612 "cloned file should exist"
1613 );
1614 let content = std::fs::read_to_string(clone_path.join("file.txt")).unwrap();
1615 assert_eq!(content, "hello", "cloned file should have original content");
1616
1617 assert!(
1619 clone_path.join(".git").exists(),
1620 "cloned directory should be a git repo"
1621 );
1622 }
1623
1624 #[test]
1625 fn git_clone_with_fallback_invalid_url() {
1626 let dir = tempfile::tempdir().unwrap();
1627 let target = dir.path().join("clone");
1628 std::fs::create_dir_all(&target).unwrap();
1629
1630 let printer = test_printer();
1631 let err = git_clone_with_fallback("file:///nonexistent/path/repo", &target, &printer)
1632 .unwrap_err();
1633 assert!(
1634 err.contains("Failed to clone") || err.contains("nonexistent"),
1635 "expected clone failure message, got: {err}"
1636 );
1637 }
1638
1639 #[test]
1640 fn remove_source_cleans_up_directory() {
1641 let dir = tempfile::tempdir().unwrap();
1642 let source_path = dir.path().join("removable");
1643 std::fs::create_dir_all(&source_path).unwrap();
1644 std::fs::write(source_path.join("data.txt"), "test").unwrap();
1645
1646 let mut mgr = SourceManager::new(dir.path());
1647 insert_fake_source(&mut mgr, "removable", source_path.clone());
1648
1649 assert!(
1651 source_path.exists(),
1652 "source directory should exist before removal"
1653 );
1654 assert!(
1655 source_path.join("data.txt").exists(),
1656 "data file should exist before removal"
1657 );
1658 assert!(
1659 mgr.get("removable").is_some(),
1660 "source should be in cache before removal"
1661 );
1662
1663 mgr.remove_source("removable")
1664 .expect("remove_source should succeed for existing cached source");
1665
1666 assert!(
1668 !source_path.exists(),
1669 "source directory should be deleted after removal"
1670 );
1671 assert!(
1672 mgr.get("removable").is_none(),
1673 "source should be removed from cache"
1674 );
1675 assert!(
1676 mgr.all_sources().is_empty(),
1677 "sources map should be empty after removal"
1678 );
1679 }
1680
1681 #[test]
1682 fn all_sources_returns_cached() {
1683 let dir = tempfile::tempdir().unwrap();
1684 let mut mgr = SourceManager::new(dir.path());
1685
1686 let path1 = dir.path().join("src-a");
1687 let path2 = dir.path().join("src-b");
1688 std::fs::create_dir_all(&path1).unwrap();
1689 std::fs::create_dir_all(&path2).unwrap();
1690
1691 insert_fake_source(&mut mgr, "src-a", path1);
1692 insert_fake_source(&mut mgr, "src-b", path2);
1693
1694 let all = mgr.all_sources();
1695 assert_eq!(all.len(), 2);
1696 assert!(all.contains_key("src-a"));
1697 assert!(all.contains_key("src-b"));
1698 }
1699
1700 #[test]
1703 fn build_source_spec_ssh_url() {
1704 let spec = SourceManager::build_source_spec("corp", "git@gitlab.com:corp/config.git", None);
1705 assert_eq!(spec.name, "corp");
1706 assert_eq!(spec.origin.url, "git@gitlab.com:corp/config.git");
1707 assert_eq!(spec.origin.branch, "master");
1708 assert!(matches!(spec.origin.origin_type, OriginType::Git));
1709 assert!(spec.origin.auth.is_none());
1710 assert!(spec.subscription.profile.is_none());
1711 assert!(!spec.subscription.accept_recommended);
1712 assert!(spec.subscription.opt_in.is_empty());
1713 assert!(!spec.sync.auto_apply);
1714 assert!(spec.sync.pin_version.is_none());
1715 }
1716
1717 #[test]
1718 fn build_source_spec_with_profile_sets_subscription() {
1719 let spec = SourceManager::build_source_spec(
1720 "team",
1721 "https://github.com/team/dotfiles.git",
1722 Some("devops"),
1723 );
1724 assert_eq!(spec.subscription.profile.as_deref(), Some("devops"));
1725 assert_eq!(spec.subscription.priority, 500);
1726 assert_eq!(spec.sync.interval, "1h");
1727 }
1728
1729 #[test]
1730 fn build_source_spec_preserves_url_verbatim() {
1731 let url = "ssh://git@internal.host:2222/repo.git";
1732 let spec = SourceManager::build_source_spec("internal", url, None);
1733 assert_eq!(spec.origin.url, url);
1734 }
1735
1736 #[test]
1739 fn parse_manifest_with_profile_details() {
1740 let dir = tempfile::tempdir().unwrap();
1741 std::fs::write(
1742 dir.path().join(SOURCE_MANIFEST_FILE),
1743 r#"
1744apiVersion: cfgd.io/v1alpha1
1745kind: ConfigSource
1746metadata:
1747 name: detailed-source
1748 version: "2.0.0"
1749 description: "A source with profile details"
1750spec:
1751 provides:
1752 profiles: []
1753 profileDetails:
1754 - name: backend
1755 description: "Backend developer profile"
1756 inherits:
1757 - base
1758 - name: frontend
1759 description: "Frontend developer profile"
1760 modules:
1761 - docker
1762 - kubernetes
1763 policy: {}
1764"#,
1765 )
1766 .unwrap();
1767
1768 let mgr = SourceManager::new(dir.path());
1769 let manifest = mgr.parse_manifest("detailed", dir.path()).unwrap();
1770 assert_eq!(manifest.metadata.name, "detailed-source");
1771 assert_eq!(manifest.metadata.version.as_deref(), Some("2.0.0"));
1772 assert_eq!(
1773 manifest.metadata.description.as_deref(),
1774 Some("A source with profile details")
1775 );
1776 assert_eq!(manifest.spec.provides.profile_details.len(), 2);
1777 assert_eq!(manifest.spec.provides.profile_details[0].name, "backend");
1778 assert_eq!(
1779 manifest.spec.provides.profile_details[0]
1780 .description
1781 .as_deref(),
1782 Some("Backend developer profile")
1783 );
1784 assert_eq!(
1785 manifest.spec.provides.profile_details[0].inherits,
1786 vec!["base"]
1787 );
1788 assert_eq!(manifest.spec.provides.profile_details[1].name, "frontend");
1789 assert_eq!(manifest.spec.provides.modules, vec!["docker", "kubernetes"]);
1790 }
1791
1792 #[test]
1793 fn parse_manifest_with_platform_profiles() {
1794 let dir = tempfile::tempdir().unwrap();
1795 std::fs::write(
1796 dir.path().join(SOURCE_MANIFEST_FILE),
1797 r#"
1798apiVersion: cfgd.io/v1alpha1
1799kind: ConfigSource
1800metadata:
1801 name: platform-source
1802spec:
1803 provides:
1804 profiles:
1805 - base
1806 platformProfiles:
1807 macos: macos-base
1808 linux: linux-base
1809 policy: {}
1810"#,
1811 )
1812 .unwrap();
1813
1814 let mgr = SourceManager::new(dir.path());
1815 let manifest = mgr.parse_manifest("plat", dir.path()).unwrap();
1816 assert_eq!(
1817 manifest.spec.provides.platform_profiles.get("macos"),
1818 Some(&"macos-base".to_string())
1819 );
1820 assert_eq!(
1821 manifest.spec.provides.platform_profiles.get("linux"),
1822 Some(&"linux-base".to_string())
1823 );
1824 }
1825
1826 #[test]
1829 fn config_source_document_serde_roundtrip() {
1830 let doc = ConfigSourceDocument {
1831 api_version: crate::API_VERSION.into(),
1832 kind: "ConfigSource".into(),
1833 metadata: crate::config::ConfigSourceMetadata {
1834 name: "roundtrip-test".into(),
1835 version: Some("1.2.3".into()),
1836 description: Some("Test description".into()),
1837 },
1838 spec: crate::config::ConfigSourceSpec {
1839 provides: crate::config::ConfigSourceProvides {
1840 profiles: vec!["base".into(), "dev".into()],
1841 profile_details: vec![crate::config::ConfigSourceProfileEntry {
1842 name: "base".into(),
1843 description: Some("Base profile".into()),
1844 path: None,
1845 inherits: vec![],
1846 }],
1847 platform_profiles: {
1848 let mut m = HashMap::new();
1849 m.insert("macos".into(), "macos-base".into());
1850 m
1851 },
1852 modules: vec!["git".into()],
1853 },
1854 policy: Default::default(),
1855 },
1856 };
1857
1858 let yaml = serde_yaml::to_string(&doc).expect("serialize should succeed");
1859 let parsed: ConfigSourceDocument =
1860 serde_yaml::from_str(&yaml).expect("deserialize should succeed");
1861
1862 assert_eq!(parsed.metadata.name, "roundtrip-test");
1863 assert_eq!(parsed.metadata.version.as_deref(), Some("1.2.3"));
1864 assert_eq!(
1865 parsed.metadata.description.as_deref(),
1866 Some("Test description")
1867 );
1868 assert_eq!(parsed.spec.provides.profiles, vec!["base", "dev"]);
1869 assert_eq!(parsed.spec.provides.profile_details.len(), 1);
1870 assert_eq!(parsed.spec.provides.profile_details[0].name, "base");
1871 assert_eq!(
1872 parsed
1873 .spec
1874 .provides
1875 .platform_profiles
1876 .get("macos")
1877 .map(String::as_str),
1878 Some("macos-base")
1879 );
1880 assert_eq!(parsed.spec.provides.modules, vec!["git"]);
1881 }
1882
1883 #[test]
1884 fn config_source_document_deserialize_minimal() {
1885 let yaml = r#"
1886apiVersion: cfgd.io/v1alpha1
1887kind: ConfigSource
1888metadata:
1889 name: minimal
1890spec:
1891 provides:
1892 profiles:
1893 - default
1894"#;
1895 let doc: ConfigSourceDocument =
1896 serde_yaml::from_str(yaml).expect("minimal manifest should parse");
1897 assert_eq!(doc.metadata.name, "minimal");
1898 assert!(doc.metadata.version.is_none());
1899 assert!(doc.metadata.description.is_none());
1900 assert_eq!(doc.spec.provides.profiles, vec!["default"]);
1901 assert!(doc.spec.provides.profile_details.is_empty());
1902 assert!(doc.spec.provides.platform_profiles.is_empty());
1903 assert!(doc.spec.provides.modules.is_empty());
1904 assert!(!doc.spec.policy.constraints.require_signed_commits);
1906 }
1907
1908 #[test]
1911 fn read_manifest_unreadable_yaml_content() {
1912 let dir = tempfile::tempdir().unwrap();
1913 std::fs::write(
1914 dir.path().join(SOURCE_MANIFEST_FILE),
1915 "this is not yaml at all: [[[",
1916 )
1917 .unwrap();
1918
1919 let err = read_manifest("bad-yaml", dir.path()).unwrap_err();
1920 let msg = err.to_string();
1921 assert!(
1922 msg.contains("bad-yaml") || msg.contains("invalid") || msg.contains("ConfigSource"),
1923 "expected manifest parse error mentioning the source name, got: {msg}"
1924 );
1925 }
1926
1927 #[test]
1928 fn read_manifest_wrong_kind_in_yaml() {
1929 let dir = tempfile::tempdir().unwrap();
1930 std::fs::write(
1931 dir.path().join(SOURCE_MANIFEST_FILE),
1932 r#"
1933apiVersion: cfgd.io/v1alpha1
1934kind: Config
1935metadata:
1936 name: wrong-kind
1937spec: {}
1938"#,
1939 )
1940 .unwrap();
1941
1942 let result = read_manifest("wrong-kind", dir.path());
1944 assert!(
1945 result.is_err(),
1946 "wrong kind should be rejected by parse_config_source"
1947 );
1948 }
1949
1950 #[test]
1951 fn parse_manifest_with_policy_constraints() {
1952 let dir = tempfile::tempdir().unwrap();
1953 std::fs::write(
1954 dir.path().join(SOURCE_MANIFEST_FILE),
1955 r#"
1956apiVersion: cfgd.io/v1alpha1
1957kind: ConfigSource
1958metadata:
1959 name: constrained-source
1960spec:
1961 provides:
1962 profiles:
1963 - secure
1964 policy:
1965 constraints:
1966 requireSignedCommits: true
1967 noScripts: true
1968 noSecretsRead: false
1969 allowSystemChanges: true
1970"#,
1971 )
1972 .unwrap();
1973
1974 let mgr = SourceManager::new(dir.path());
1975 let manifest = mgr.parse_manifest("constrained", dir.path()).unwrap();
1976 assert!(manifest.spec.policy.constraints.require_signed_commits);
1977 assert!(manifest.spec.policy.constraints.no_scripts);
1978 assert!(!manifest.spec.policy.constraints.no_secrets_read);
1979 assert!(manifest.spec.policy.constraints.allow_system_changes);
1980 }
1981
1982 #[test]
1985 fn cached_source_fields_via_get() {
1986 let dir = tempfile::tempdir().unwrap();
1987 let source_path = dir.path().join("my-src");
1988 std::fs::create_dir_all(&source_path).unwrap();
1989
1990 let mut mgr = SourceManager::new(dir.path());
1991 insert_fake_source(&mut mgr, "my-src", source_path.clone());
1992
1993 let cached = mgr.get("my-src").expect("source should be cached");
1994 assert_eq!(cached.name, "my-src");
1995 assert_eq!(cached.origin_url, "https://example.com/config.git");
1996 assert_eq!(cached.origin_branch, "main");
1997 assert_eq!(cached.local_path, source_path);
1998 assert_eq!(cached.manifest.kind, "ConfigSource");
1999 assert!(cached.last_commit.is_none());
2000 assert!(cached.last_fetched.is_none());
2001 }
2002
2003 #[test]
2006 fn normalize_semver_pin_tilde_three_part() {
2007 assert_eq!(normalize_semver_pin("~1.2.3"), "~1.2.3");
2009 }
2010
2011 #[test]
2012 fn normalize_semver_pin_caret_three_part() {
2013 assert_eq!(normalize_semver_pin("^0.1.2"), "^0.1.2");
2014 }
2015
2016 #[test]
2017 fn normalize_semver_pin_comparison_operators() {
2018 assert_eq!(normalize_semver_pin(">1.0.0"), ">1.0.0");
2020 assert_eq!(normalize_semver_pin("<=2.0.0"), "<=2.0.0");
2021 assert_eq!(normalize_semver_pin(">=1.5.0, <2.0.0"), ">=1.5.0, <2.0.0");
2022 }
2023
2024 #[test]
2025 fn normalize_semver_pin_wildcard() {
2026 assert_eq!(normalize_semver_pin("*"), "*");
2027 }
2028
2029 #[test]
2032 fn source_spec_serde_roundtrip() {
2033 let spec = SourceManager::build_source_spec(
2034 "my-source",
2035 "https://github.com/org/config.git",
2036 Some("engineering"),
2037 );
2038 let yaml = serde_yaml::to_string(&spec).expect("serialize should succeed");
2039 let parsed: crate::config::SourceSpec =
2040 serde_yaml::from_str(&yaml).expect("deserialize should succeed");
2041
2042 assert_eq!(parsed.name, "my-source");
2043 assert_eq!(parsed.origin.url, "https://github.com/org/config.git");
2044 assert_eq!(parsed.origin.branch, "master");
2045 assert_eq!(parsed.subscription.profile.as_deref(), Some("engineering"));
2046 assert_eq!(parsed.subscription.priority, 500);
2047 assert_eq!(parsed.sync.interval, "1h");
2048 assert!(!parsed.sync.auto_apply);
2049 }
2050
2051 #[test]
2054 fn detect_source_manifest_with_profile_details_only() {
2055 let dir = tempfile::tempdir().unwrap();
2056 std::fs::write(
2057 dir.path().join(SOURCE_MANIFEST_FILE),
2058 r#"
2059apiVersion: cfgd.io/v1alpha1
2060kind: ConfigSource
2061metadata:
2062 name: details-only
2063spec:
2064 provides:
2065 profiles: []
2066 profileDetails:
2067 - name: dev
2068 description: "Developer profile"
2069 policy: {}
2070"#,
2071 )
2072 .unwrap();
2073
2074 let result = detect_source_manifest(dir.path()).unwrap();
2075 assert!(
2076 result.is_some(),
2077 "should accept profile_details as valid profiles"
2078 );
2079 let doc = result.unwrap();
2080 assert_eq!(doc.metadata.name, "details-only");
2081 assert_eq!(doc.spec.provides.profile_details.len(), 1);
2082 }
2083
2084 #[test]
2087 fn load_source_rejects_file_url() {
2088 let dir = tempfile::tempdir().unwrap();
2089 let mut mgr = SourceManager::new(dir.path());
2090 let printer = test_printer();
2091
2092 let spec = crate::config::SourceSpec {
2093 name: "local-bad".into(),
2094 origin: crate::config::OriginSpec {
2095 origin_type: OriginType::Git,
2096 url: "file:///etc/shadow".into(),
2097 branch: "main".into(),
2098 auth: None,
2099 ssh_strict_host_key_checking: Default::default(),
2100 },
2101 subscription: Default::default(),
2102 sync: Default::default(),
2103 };
2104
2105 let result = mgr.load_source(&spec, &printer);
2106 assert!(result.is_err());
2107 let err = result.unwrap_err().to_string();
2108 assert!(
2109 err.contains("local file://") || err.contains("not allowed"),
2110 "expected file:// rejection, got: {err}"
2111 );
2112 }
2113
2114 #[test]
2115 fn load_source_rejects_absolute_path_url() {
2116 let dir = tempfile::tempdir().unwrap();
2117 let mut mgr = SourceManager::new(dir.path());
2118 let printer = test_printer();
2119
2120 let spec = crate::config::SourceSpec {
2121 name: "abs-bad".into(),
2122 origin: crate::config::OriginSpec {
2123 origin_type: OriginType::Git,
2124 url: "/tmp/local-repo".into(),
2125 branch: "main".into(),
2126 auth: None,
2127 ssh_strict_host_key_checking: Default::default(),
2128 },
2129 subscription: Default::default(),
2130 sync: Default::default(),
2131 };
2132
2133 let result = mgr.load_source(&spec, &printer);
2134 assert!(result.is_err());
2135 let err = result.unwrap_err().to_string();
2136 assert!(
2137 err.contains("not allowed") || err.contains("absolute path"),
2138 "expected absolute path rejection, got: {err}"
2139 );
2140 }
2141
2142 #[test]
2143 fn load_source_rejects_file_url_case_insensitive() {
2144 let dir = tempfile::tempdir().unwrap();
2145 let mut mgr = SourceManager::new(dir.path());
2146 let printer = test_printer();
2147
2148 let spec = crate::config::SourceSpec {
2149 name: "case-bad".into(),
2150 origin: crate::config::OriginSpec {
2151 origin_type: OriginType::Git,
2152 url: "FILE:///etc/passwd".into(),
2153 branch: "main".into(),
2154 auth: None,
2155 ssh_strict_host_key_checking: Default::default(),
2156 },
2157 subscription: Default::default(),
2158 sync: Default::default(),
2159 };
2160
2161 let result = mgr.load_source(&spec, &printer);
2162 assert!(result.is_err(), "FILE:// should also be rejected");
2163 }
2164
2165 #[test]
2168 fn remove_source_missing_directory_still_removes_cache_entry() {
2169 let dir = tempfile::tempdir().unwrap();
2170 let missing_path = dir.path().join("already-gone");
2171 let mut mgr = SourceManager::new(dir.path());
2174 insert_fake_source(&mut mgr, "already-gone", missing_path.clone());
2175
2176 mgr.remove_source("already-gone")
2178 .expect("remove should succeed when directory is already gone");
2179 assert!(mgr.get("already-gone").is_none());
2180 }
2181
2182 #[test]
2185 fn check_version_pin_exact_match() {
2186 let dir = tempfile::tempdir().unwrap();
2187 let mgr = SourceManager::new(dir.path());
2188
2189 let manifest = ConfigSourceDocument {
2190 api_version: crate::API_VERSION.into(),
2191 kind: "ConfigSource".into(),
2192 metadata: crate::config::ConfigSourceMetadata {
2193 name: "test".into(),
2194 version: Some("1.2.3".into()),
2195 description: None,
2196 },
2197 spec: crate::config::ConfigSourceSpec {
2198 provides: Default::default(),
2199 policy: Default::default(),
2200 },
2201 };
2202
2203 mgr.check_version_pin("test", &manifest, "=1.2.3")
2204 .expect("exact version should match");
2205 }
2206
2207 #[test]
2208 fn check_version_pin_exact_mismatch() {
2209 let dir = tempfile::tempdir().unwrap();
2210 let mgr = SourceManager::new(dir.path());
2211
2212 let manifest = ConfigSourceDocument {
2213 api_version: crate::API_VERSION.into(),
2214 kind: "ConfigSource".into(),
2215 metadata: crate::config::ConfigSourceMetadata {
2216 name: "test".into(),
2217 version: Some("1.2.3".into()),
2218 description: None,
2219 },
2220 spec: crate::config::ConfigSourceSpec {
2221 provides: Default::default(),
2222 policy: Default::default(),
2223 },
2224 };
2225
2226 let result = mgr.check_version_pin("test", &manifest, "=2.0.0");
2227 assert!(result.is_err());
2228 }
2229
2230 #[test]
2233 fn verify_signature_required_but_allow_unsigned_skips() {
2234 let dir = tempfile::tempdir().unwrap();
2235 let mut mgr = SourceManager::new(dir.path());
2236 mgr.set_allow_unsigned(true);
2237
2238 let constraints = crate::config::SourceConstraints {
2239 require_signed_commits: true,
2240 ..Default::default()
2241 };
2242
2243 let result = mgr.verify_commit_signature("test", dir.path(), &constraints);
2246 assert!(result.is_ok());
2247 }
2248
2249 #[test]
2252 fn head_commit_empty_repo_returns_none() {
2253 let dir = tempfile::tempdir().unwrap();
2254 let repo_dir = dir.path().join("empty-repo");
2255 git2::Repository::init(&repo_dir).unwrap();
2256 let result = SourceManager::head_commit(&repo_dir);
2258 assert!(
2259 result.is_none(),
2260 "empty repo with no commits should return None"
2261 );
2262 }
2263
2264 #[test]
2267 fn source_manager_get_and_all_sources_consistent() {
2268 let dir = tempfile::tempdir().unwrap();
2269 let mut mgr = SourceManager::new(dir.path());
2270
2271 assert!(mgr.all_sources().is_empty());
2272 assert!(mgr.get("nonexistent").is_none());
2273
2274 let path = dir.path().join("src");
2275 std::fs::create_dir_all(&path).unwrap();
2276 insert_fake_source(&mut mgr, "src", path);
2277
2278 assert_eq!(mgr.all_sources().len(), 1);
2279 assert!(mgr.get("src").is_some());
2280 assert!(mgr.get("other").is_none());
2281 }
2282
2283 #[test]
2286 fn load_source_profile_no_profiles_directory() {
2287 let dir = tempfile::tempdir().unwrap();
2288 let source_path = dir.path().join("src-no-profiles");
2289 std::fs::create_dir_all(&source_path).unwrap();
2290 let mut mgr = SourceManager::new(dir.path());
2293 insert_fake_source(&mut mgr, "src-no-profiles", source_path);
2294
2295 let result = mgr.load_source_profile("src-no-profiles", "default");
2296 assert!(result.is_err());
2297 let err = result.unwrap_err().to_string();
2298 assert!(
2299 err.contains("not found") || err.contains("ProfileNotFound"),
2300 "expected profile not found error, got: {err}"
2301 );
2302 }
2303}