Skip to main content

cfgd_core/sources/
mod.rs

1// Sources — multi-source config management
2// Manages fetching, caching, and tracking external config sources (git repos).
3// Dependency rules: depends only on config/, output/, errors/. Must NOT import
4// files/, packages/, secrets/, reconciler/, providers/.
5
6use 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/// Cached state for a single config source.
22#[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
33/// Manager for multiple config sources — handles fetching, caching, version checking.
34pub struct SourceManager {
35    cache_dir: PathBuf,
36    sources: HashMap<String, CachedSource>,
37    /// When true, skip signature verification even if a source requires it.
38    allow_unsigned: bool,
39}
40
41impl SourceManager {
42    /// Create a new SourceManager using the given cache directory.
43    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    /// Set whether to allow unsigned source content (bypasses signature verification).
52    pub fn set_allow_unsigned(&mut self, allow: bool) {
53        self.allow_unsigned = allow;
54    }
55
56    /// Default cache directory: ~/.local/share/cfgd/sources/
57    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    /// Load all sources from config, fetching if needed.
65    /// Returns an error if sources were specified but none loaded successfully.
66    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    /// Load a single source — clone or fetch, parse manifest, check version.
87    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        // Reject local file URLs to prevent local filesystem access from composed sources.
96        // CFGD_ALLOW_LOCAL_SOURCES bypasses this for dev/test environments only.
97        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        // Signature verification: if the source requires signed commits, verify HEAD
119        self.verify_commit_signature(&spec.name, &source_dir, &manifest.spec.policy.constraints)?;
120
121        // Version pinning check
122        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    /// Fetch (pull) updates for an already-cloned source.
143    fn fetch_source(&self, spec: &SourceSpec, source_dir: &Path, printer: &Printer) -> Result<()> {
144        // Try git CLI first with live progress output.
145        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        // Ensure stderr is captured (git progress goes to stderr)
157        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            // Fall back to libgit2 with spinner
166            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        // Fast-forward to FETCH_HEAD
197        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    /// Clone a new source repo. Tries git CLI first (respects system credential
247    /// helpers and SSH config), falls back to libgit2.
248    fn clone_source(&self, spec: &SourceSpec, source_dir: &Path, printer: &Printer) -> Result<()> {
249        // Ensure parent dir exists but not source_dir itself (git clone creates it)
250        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        // Try git CLI first with live progress output.
257        // --depth=1: only fetch latest commit (limits repo size for DoS protection)
258        // --no-recurse-submodules: prevent malicious submodule URLs (SSRF, credential theft)
259        // --single-branch: only fetch the target branch
260        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            // Restrict cloned directory to owner-only access
281            let _ = crate::set_file_permissions(source_dir, 0o700);
282            return Ok(());
283        }
284
285        // Clean up partial clone before libgit2 retry
286        let _ = std::fs::remove_dir_all(source_dir);
287
288        // Fall back to libgit2 with spinner
289        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        // Shallow clone with depth 1, disable submodule init
299        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        // Restrict cloned directory to owner-only access
316        let _ = crate::set_file_permissions(source_dir, 0o700);
317
318        Ok(())
319    }
320
321    /// Parse the ConfigSource manifest from a source directory.
322    pub fn parse_manifest(&self, name: &str, source_dir: &Path) -> Result<ConfigSourceDocument> {
323        read_manifest(name, source_dir)
324    }
325
326    /// Verify the HEAD commit of a source repo has a valid GPG or SSH signature.
327    /// Checks `allow_unsigned` on this SourceManager and `require_signed_commits`
328    /// on the constraints before delegating to `verify_head_signature`.
329    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    /// Check version pin against source manifest version.
352    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        // Support tilde (~2) as shorthand for ~2.0.0
366        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    /// Get the HEAD commit hash for a repo.
386    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    /// Get a cached source by name.
393    pub fn get(&self, name: &str) -> Option<&CachedSource> {
394        self.sources.get(name)
395    }
396
397    /// Get all cached sources.
398    pub fn all_sources(&self) -> &HashMap<String, CachedSource> {
399        &self.sources
400    }
401
402    /// Load a profile from a source's profiles directory.
403    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    /// Get the source profiles directory path.
432    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    /// Get the source files directory path.
443    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    /// Remove a source from cache.
454    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    /// Build a SourceSpec for adding a new source.
472    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
491/// Read and parse a cfgd-source.yaml manifest from a directory.
492fn 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
523/// Check if a directory contains a cfgd-source.yaml manifest.
524/// Returns Ok(Some(doc)) if found and valid, Ok(None) if not present,
525/// Err if file exists but is invalid.
526pub 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
538/// Verify that the HEAD commit of a git repo has a valid GPG or SSH signature.
539/// Uses `git log --format=%G?` to check signature status. Returns Ok(()) if the
540/// signature is valid (G) or valid with unknown trust (U). Returns an error for
541/// unsigned, bad, expired, revoked, or unverifiable signatures.
542pub 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 = good valid signature, U = good signature with unknown validity (untrusted key)
586        "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
627/// Normalize shorthand semver pins.
628/// `~2` means "any 2.x.x" (maps to `>=2.0.0, <3.0.0`, i.e., caret semantics).
629/// `~2.1` means "any 2.1.x" (maps to `~2.1.0`, tilde semantics).
630/// `~2.1.0` is passed through directly.
631/// `^N` uses caret semantics as-is.
632fn 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            // ~2 -> ^2.0.0 (user means "any v2")
639            0 => format!("^{}.0.0", rest),
640            // ~2.1 -> ~2.1.0 (user means "any 2.1.x")
641            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
656/// Clone a git repo with git CLI (with live progress), falling back to libgit2.
657/// Returns Ok(()) on success, Err with description on failure.
658pub fn git_clone_with_fallback(
659    url: &str,
660    target: &Path,
661    printer: &Printer,
662) -> std::result::Result<(), String> {
663    // Try git CLI first with live progress output.
664    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    // Clean up partial clone before libgit2 retry
682    let _ = std::fs::remove_dir_all(target);
683    let _ = std::fs::create_dir_all(target);
684
685    // Fall back to libgit2 with spinner
686    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        // ~N -> ^N.0.0 (any version N.x.x)
715        assert_eq!(normalize_semver_pin("~2"), "^2.0.0");
716        // ~N.M -> ~N.M.0 (any version N.M.x)
717        assert_eq!(normalize_semver_pin("~2.1"), "~2.1.0");
718        // Full version passed through
719        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        // All three pins should match version 2.1.0 — unwrap to prove success
893        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        // require_signed_commits defaults to false — should return Ok(()) without any repo
938        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        // Even though require_signed_commits is true, allow_unsigned bypasses it
961        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        // Create a real git repo with an unsigned commit
972        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        // Create a git repo with an unsigned commit using git2 directly
1008        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        // Create a file and commit it
1016        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        // The public function verify_head_signature should fail on unsigned commits
1026        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        // No prefix — passed through as-is
1075        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, // No version — defaults to 0.0.0
1090                description: None,
1091            },
1092            spec: crate::config::ConfigSourceSpec {
1093                provides: Default::default(),
1094                policy: Default::default(),
1095            },
1096        };
1097
1098        // ~0 matches 0.0.0
1099        mgr.check_version_pin("test", &manifest, "~0")
1100            .expect("~0 should match defaulted version 0.0.0");
1101        // ~1 does NOT match 0.0.0
1102        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        // detect_source_manifest delegates to read_manifest which should fail
1213        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        // This test may fail in environments without a home directory,
1237        // but in normal test environments it should work.
1238        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        // Default sync interval
1253        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        // ~2.1 should match 2.1.x but not 2.2.0
1287        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        // Create a fake cached source directory on disk
1337        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        // Manually insert a CachedSource into the manager's internal map
1343        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        // Verify the source is present
1367        assert!(mgr.get("test-source").is_some());
1368
1369        // Remove the source
1370        mgr.remove_source("test-source")
1371            .expect("remove_source should succeed for existing cached source");
1372
1373        // Verify it was removed from the map
1374        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        // Verify the directory and its contents were removed from disk
1381        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    /// Helper: insert a fake CachedSource into a SourceManager for testing
1389    /// methods that operate on already-cached sources.
1390    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        // Write a valid profile YAML
1422        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        // No profile file written
1456
1457        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        // Use a manually created repo
1503        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        // Create specs that point to non-existent repos
1533        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        // Empty list should succeed and leave no sources loaded
1576        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        // Create a bare repo as the origin
1590        let repo = git2::Repository::init(&origin_path).unwrap();
1591        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
1592        // Use content without a trailing newline. Git for Windows defaults to
1593        // core.autocrlf=true, which rewrites LF → CRLF on checkout and would
1594        // make the byte comparison below platform-dependent.
1595        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        // Verify the cloned file exists with the correct content
1610        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        // Verify it is a valid git repo
1618        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        // Pre-conditions: source exists on disk and in cache
1650        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        // Post-conditions: both directory and cache entry are gone
1667        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    // ─── build_source_spec — field verification ──────────────────
1701
1702    #[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    // ─── parse_manifest — profile_details support ────────────────
1737
1738    #[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    // ─── ConfigSourceDocument serialization roundtrip ─────────────
1827
1828    #[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        // Policy defaults
1905        assert!(!doc.spec.policy.constraints.require_signed_commits);
1906    }
1907
1908    // ─── read_manifest — additional edge cases ───────────────────
1909
1910    #[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        // parse_config_source validates the kind field — this should fail
1943        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    // ─── CachedSource field verification ─────────────────────────
1983
1984    #[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    // ─── normalize_semver_pin — more edge cases ──────────────────
2004
2005    #[test]
2006    fn normalize_semver_pin_tilde_three_part() {
2007        // Full three-part tilde passed through unchanged
2008        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        // Operators other than ~ and ^ are passed through
2019        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    // ─── SourceSpec serialization ────────────────────────────────
2030
2031    #[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    // ─── detect_source_manifest — with profile_details ───────────
2052
2053    #[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    // --- load_source: local file URL rejection ---
2085
2086    #[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    // --- remove_source: already-deleted directory ---
2166
2167    #[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        // Do NOT create the directory — simulate it being deleted externally
2172
2173        let mut mgr = SourceManager::new(dir.path());
2174        insert_fake_source(&mut mgr, "already-gone", missing_path.clone());
2175
2176        // Should succeed even though directory doesn't exist
2177        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    // --- check_version_pin: exact version match ---
2183
2184    #[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    // --- verify_commit_signature: constraints control ---
2231
2232    #[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        // Even though require_signed_commits is true, allow_unsigned bypasses it
2244        // This should succeed without even checking the repo
2245        let result = mgr.verify_commit_signature("test", dir.path(), &constraints);
2246        assert!(result.is_ok());
2247    }
2248
2249    // --- head_commit: empty repo ---
2250
2251    #[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        // No commits yet
2257        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    // --- SourceManager: multiple operations ---
2265
2266    #[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    // --- load_source_profile: missing profile file variant ---
2284
2285    #[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        // Don't create the profiles subdirectory
2291
2292        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}