Skip to main content

devboy_storage/
merge.rs

1//! Manifest-with-global-index merge per [ADR-020] §4.
2//!
3//! Given a global index ([`GlobalIndex`]) and a per-project manifest
4//! ([`ProjectManifest`]), the merge function produces a single
5//! resolved view per declared path: which entry "wins", which fields
6//! came from the manifest's `[overrides]` block, and what the final
7//! metadata is.
8//!
9//! # Precedence
10//!
11//! For each path mentioned in the manifest's `required` or `optional`
12//! list, the resolver applies precedence rules straight from ADR-020 §4:
13//!
14//! 1. If the path appears as `[secret."<path>"]` in the manifest, its
15//!    metadata is read from the manifest only — the path is
16//!    project-local.
17//! 2. Otherwise, metadata is read from the global index. If the
18//!    manifest has an `[overrides."<path>"]` block, the listed
19//!    behavioural fields override the index values.
20//! 3. If a path appears in `required` / `optional` and exists in
21//!    **neither** the global index nor as `[secret."..."]`, the
22//!    merge fails with [`MergeError::UnknownPath`] — the
23//!    `E_SECRET_UNKNOWN_PATH` error from the ADR.
24//!
25//! `E_OVERRIDE_FIELD_NOT_ALLOWED` (the ADR's name for "you tried to
26//! override a non-behavioural field") is already enforced at *parse*
27//! time by `deny_unknown_fields` on [`OverrideEntry`] — it surfaces
28//! through [`ManifestError::Parse`](crate::manifest::ManifestError::Parse)
29//! during loading, not here.
30//!
31//! # Warnings (advisory)
32//!
33//! The merge also emits non-fatal warnings, returned alongside the
34//! resolved view. `doctor` consumes them; the loader itself accepts
35//! the manifest. Cases:
36//!
37//! - [`MergeWarningKind::NoOpOverride`] — an `[overrides]` field is set to
38//!   the same value as the global. The override is harmless but adds
39//!   noise; suggest removal.
40//! - [`MergeWarningKind::OverrideForUndeclaredPath`] — an `[overrides."x"]`
41//!   block exists for a path that is not in `required` / `optional`.
42//!   Possibly a typo or a leftover; the override is unused.
43//! - [`MergeWarningKind::ProjectLocalForUndeclaredPath`] — a
44//!   `[secret."x"]` block exists for a path that is not in
45//!   `required` / `optional`. Same shape as the previous warning;
46//!   metadata is registered but no caller depends on it.
47//!
48//! [ADR-020]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-020-secret-manifest-and-alias-resolution.md
49
50use std::collections::BTreeMap;
51
52use serde::Serialize;
53use thiserror::Error;
54
55use crate::index::{GlobalIndex, IndexEntry};
56use crate::manifest::{OverrideEntry, PathRole, ProjectManifest};
57use crate::secret_path::SecretPath;
58
59/// Which behavioural field of a global entry was replaced by the
60/// project's `[overrides]` block.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
62#[serde(rename_all = "snake_case")]
63pub enum OverrideField {
64    /// The `gate` field.
65    Gate,
66    /// The `rotate_every_days` field.
67    RotateEveryDays,
68    /// The `description` field.
69    Description,
70    /// The `approve_on_use` field (P25).
71    ApproveOnUse,
72}
73
74/// Where the resolved metadata for a path came from.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum SecretOrigin {
77    /// The path's metadata lives entirely in the project manifest's
78    /// `[secret."<path>"]` block (rule 1 of the precedence).
79    ProjectLocal,
80    /// The path's metadata came from the global index, optionally with
81    /// behavioural overrides applied (rule 2 of the precedence).
82    Global {
83        /// Which override fields were actually applied. Empty when
84        /// the path has no `[overrides]` block.
85        overrides_applied: Vec<OverrideField>,
86    },
87}
88
89/// Resolved view of a single declared path — final metadata plus
90/// provenance.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct ResolvedSecret {
93    /// The path (validated through ADR-020 §2 by the loaders that
94    /// produced it).
95    pub path: SecretPath,
96    /// `true` when the path was declared in the manifest's `required`
97    /// list; `false` when it was in `optional`.
98    pub required: bool,
99    /// Where the resolved metadata came from.
100    pub origin: SecretOrigin,
101    /// Final, post-override metadata.
102    pub metadata: IndexEntry,
103}
104
105/// Output of [`merge_manifest`] — the resolved view plus advisory
106/// warnings.
107#[derive(Debug, Default, Clone, PartialEq, Eq)]
108pub struct MergeOutput {
109    /// One [`ResolvedSecret`] per declared path, sorted by path.
110    pub secrets: BTreeMap<SecretPath, ResolvedSecret>,
111    /// Non-fatal warnings produced during the merge.
112    pub warnings: Vec<MergeWarning>,
113}
114
115/// Advisory warning emitted by the merge.
116///
117/// Warnings do not fail the merge; they are surfaced through `doctor`
118/// to nudge the user toward fixing manifest hygiene issues.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
120pub struct MergeWarning {
121    /// What the warning is about.
122    pub kind: MergeWarningKind,
123    /// Which path the warning concerns.
124    pub path: SecretPath,
125}
126
127/// Categories of advisory warnings — see the module-level doc for
128/// detailed descriptions.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
130#[serde(tag = "kind", rename_all = "snake_case")]
131pub enum MergeWarningKind {
132    /// An `[overrides]` field carries the same value as the global
133    /// index. The override is a no-op; recommend removing it so the
134    /// project does not silently fall behind a future global change.
135    NoOpOverride {
136        /// Which field is identical.
137        field: OverrideField,
138    },
139    /// An `[overrides."<path>"]` block exists for a path that is not
140    /// listed in `required` or `optional`. Likely a typo or leftover.
141    OverrideForUndeclaredPath,
142    /// A `[secret."<path>"]` block exists for a path that is not
143    /// listed in `required` or `optional`. The metadata is registered
144    /// but no caller depends on it.
145    ProjectLocalForUndeclaredPath,
146}
147
148/// Fatal merge errors.
149#[derive(Debug, Error, PartialEq, Eq)]
150pub enum MergeError {
151    /// The path appears in `required` or `optional` but has no entry
152    /// in either the global index or the manifest's `[secret."..."]`
153    /// blocks. ADR-020 §4 calls this `E_SECRET_UNKNOWN_PATH`.
154    #[error(
155        "secret path '{path}' is declared in {role} but no metadata is registered for it; \
156         add a `[secret.\"{path}\"]` block to the manifest or register it in the global index \
157         (run `devboy secrets describe {path}` for guidance)"
158    )]
159    UnknownPath {
160        /// The undeclared path.
161        path: SecretPath,
162        /// Whether it was in `required` or `optional`.
163        role: PathRole,
164    },
165
166    /// An `[overrides."<path>"]` block targets a path that is also
167    /// declared as `[secret."<path>"]` (project-local). The two are
168    /// in conflict: project-local entries already carry the full
169    /// metadata, and an override on top of them silently mixes
170    /// concerns. Spec rule 1 wins (`secret` block) but the merge
171    /// rejects this configuration outright so drift cannot grow
172    /// silently.
173    #[error(
174        "secret path '{path}' has both an `[overrides.\"{path}\"]` block and a project-local \
175         `[secret.\"{path}\"]` block; remove the override (project-local entries already carry \
176         the full metadata, so an override is ambiguous)"
177    )]
178    OverrideOnProjectLocal {
179        /// The conflicting path.
180        path: SecretPath,
181    },
182}
183
184/// Merge the per-project manifest with the global index per ADR-020 §4.
185///
186/// Returns a [`MergeOutput`] holding one [`ResolvedSecret`] per declared
187/// path plus any advisory warnings. Returns [`MergeError`] on hard
188/// rule violations (`E_SECRET_UNKNOWN_PATH`, `OverrideOnProjectLocal`).
189pub fn merge_manifest(
190    global: &GlobalIndex,
191    manifest: &ProjectManifest,
192) -> Result<MergeOutput, MergeError> {
193    // Hard error: a `[secret."x"]` and an `[overrides."x"]` for the
194    // same `x` is ambiguous. Run this check up-front so the resolver
195    // body below can assume the two namespaces are disjoint per path.
196    for path in manifest.secrets.keys() {
197        if manifest.overrides.contains_key(path) {
198            return Err(MergeError::OverrideOnProjectLocal { path: path.clone() });
199        }
200    }
201
202    let mut output = MergeOutput::default();
203
204    // Resolve each declared path. We iterate `required` then `optional`
205    // so the `required` flag on `ResolvedSecret` is set correctly even
206    // for paths that appear in both lists (informally — the spec does
207    // not forbid duplication; the merge upgrades to required).
208    for (path, is_required) in manifest
209        .required
210        .iter()
211        .map(|p| (p, true))
212        .chain(manifest.optional.iter().map(|p| (p, false)))
213    {
214        let resolved = resolve_one(path, is_required, global, manifest, &mut output.warnings)?;
215        // If a path appears in both lists, the second pass (optional)
216        // would normally clobber the first. Preserve the stronger
217        // requirement instead.
218        match output.secrets.get(path) {
219            Some(existing) if existing.required && !is_required => continue,
220            _ => {
221                output.secrets.insert(path.clone(), resolved);
222            }
223        }
224    }
225
226    // Advisory warnings for `[overrides]` and `[secret]` blocks that
227    // don't correspond to any declared path.
228    for path in manifest.overrides.keys() {
229        if !is_declared(path, manifest) {
230            output.warnings.push(MergeWarning {
231                kind: MergeWarningKind::OverrideForUndeclaredPath,
232                path: path.clone(),
233            });
234        }
235    }
236    for path in manifest.secrets.keys() {
237        if !is_declared(path, manifest) {
238            output.warnings.push(MergeWarning {
239                kind: MergeWarningKind::ProjectLocalForUndeclaredPath,
240                path: path.clone(),
241            });
242        }
243    }
244
245    Ok(output)
246}
247
248fn is_declared(path: &SecretPath, manifest: &ProjectManifest) -> bool {
249    manifest.required.iter().any(|p| p == path) || manifest.optional.iter().any(|p| p == path)
250}
251
252fn resolve_one(
253    path: &SecretPath,
254    is_required: bool,
255    global: &GlobalIndex,
256    manifest: &ProjectManifest,
257    warnings: &mut Vec<MergeWarning>,
258) -> Result<ResolvedSecret, MergeError> {
259    // Rule 1: project-local entry wins outright.
260    if let Some(local) = manifest.secrets.get(path) {
261        return Ok(ResolvedSecret {
262            path: path.clone(),
263            required: is_required,
264            origin: SecretOrigin::ProjectLocal,
265            metadata: local.clone(),
266        });
267    }
268
269    // Rule 2: global entry, optionally overridden.
270    if let Some(global_entry) = global.get(path) {
271        let mut metadata = global_entry.clone();
272        let mut applied = Vec::new();
273        if let Some(over) = manifest.overrides.get(path) {
274            apply_overrides(&mut metadata, over, path, &mut applied, warnings);
275        }
276        return Ok(ResolvedSecret {
277            path: path.clone(),
278            required: is_required,
279            origin: SecretOrigin::Global {
280                overrides_applied: applied,
281            },
282            metadata,
283        });
284    }
285
286    // Rule 3: nowhere → E_SECRET_UNKNOWN_PATH.
287    Err(MergeError::UnknownPath {
288        path: path.clone(),
289        role: if is_required {
290            PathRole::Required
291        } else {
292            PathRole::Optional
293        },
294    })
295}
296
297fn apply_overrides(
298    metadata: &mut IndexEntry,
299    over: &OverrideEntry,
300    path: &SecretPath,
301    applied: &mut Vec<OverrideField>,
302    warnings: &mut Vec<MergeWarning>,
303) {
304    if let Some(g) = over.gate {
305        if metadata.default_gate == Some(g) {
306            warnings.push(MergeWarning {
307                kind: MergeWarningKind::NoOpOverride {
308                    field: OverrideField::Gate,
309                },
310                path: path.clone(),
311            });
312        }
313        metadata.default_gate = Some(g);
314        applied.push(OverrideField::Gate);
315    }
316    if let Some(d) = over.rotate_every_days {
317        if metadata.rotate_every_days == Some(d) {
318            warnings.push(MergeWarning {
319                kind: MergeWarningKind::NoOpOverride {
320                    field: OverrideField::RotateEveryDays,
321                },
322                path: path.clone(),
323            });
324        }
325        metadata.rotate_every_days = Some(d);
326        applied.push(OverrideField::RotateEveryDays);
327    }
328    if let Some(desc) = &over.description {
329        if metadata.description.as_ref() == Some(desc) {
330            warnings.push(MergeWarning {
331                kind: MergeWarningKind::NoOpOverride {
332                    field: OverrideField::Description,
333                },
334                path: path.clone(),
335            });
336        }
337        metadata.description = Some(desc.clone());
338        applied.push(OverrideField::Description);
339    }
340    if let Some(policy) = over.approve_on_use {
341        if metadata.approve_on_use == Some(policy) {
342            warnings.push(MergeWarning {
343                kind: MergeWarningKind::NoOpOverride {
344                    field: OverrideField::ApproveOnUse,
345                },
346                path: path.clone(),
347            });
348        }
349        metadata.approve_on_use = Some(policy);
350        applied.push(OverrideField::ApproveOnUse);
351    }
352}
353
354// =============================================================================
355// Tests
356// =============================================================================
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use crate::index::{Gate, IndexEntry, RotationMethod};
362
363    fn p(s: &str) -> SecretPath {
364        SecretPath::parse(s).unwrap()
365    }
366
367    // -- Happy paths -----------------------------------------------------------
368
369    #[test]
370    fn resolves_global_entry_without_overrides() {
371        let mut global = GlobalIndex::new();
372        global.insert(
373            p("team/gitlab/token-deploy"),
374            IndexEntry {
375                description: Some("Team deploy token".to_owned()),
376                default_gate: Some(Gate::Auto),
377                rotation_method: Some(RotationMethod::Manual),
378                ..IndexEntry::default()
379            },
380        );
381        let manifest = ProjectManifest {
382            required: vec![p("team/gitlab/token-deploy")],
383            ..ProjectManifest::default()
384        };
385
386        let out = merge_manifest(&global, &manifest).unwrap();
387        let resolved = out.secrets.get(&p("team/gitlab/token-deploy")).unwrap();
388        assert!(resolved.required);
389        assert_eq!(
390            resolved.origin,
391            SecretOrigin::Global {
392                overrides_applied: vec![]
393            }
394        );
395        assert_eq!(
396            resolved.metadata.description.as_deref(),
397            Some("Team deploy token")
398        );
399        assert!(out.warnings.is_empty());
400    }
401
402    #[test]
403    fn applies_overrides_on_global_entry() {
404        let mut global = GlobalIndex::new();
405        global.insert(
406            p("team/gitlab/token-deploy"),
407            IndexEntry {
408                description: Some("Team deploy token".to_owned()),
409                default_gate: Some(Gate::Auto),
410                rotate_every_days: Some(90),
411                ..IndexEntry::default()
412            },
413        );
414        let manifest = ProjectManifest {
415            required: vec![p("team/gitlab/token-deploy")],
416            overrides: BTreeMap::from([(
417                p("team/gitlab/token-deploy"),
418                OverrideEntry {
419                    gate: Some(Gate::Touchid),
420                    rotate_every_days: Some(30),
421                    description: Some("Staging deploy only".to_owned()),
422                    ..OverrideEntry::default()
423                },
424            )]),
425            ..ProjectManifest::default()
426        };
427
428        let out = merge_manifest(&global, &manifest).unwrap();
429        let resolved = out.secrets.get(&p("team/gitlab/token-deploy")).unwrap();
430
431        match &resolved.origin {
432            SecretOrigin::Global { overrides_applied } => {
433                assert_eq!(overrides_applied.len(), 3);
434                assert!(overrides_applied.contains(&OverrideField::Gate));
435                assert!(overrides_applied.contains(&OverrideField::RotateEveryDays));
436                assert!(overrides_applied.contains(&OverrideField::Description));
437            }
438            other => panic!("expected Global origin, got {other:?}"),
439        }
440        assert_eq!(resolved.metadata.default_gate, Some(Gate::Touchid));
441        assert_eq!(resolved.metadata.rotate_every_days, Some(30));
442        assert_eq!(
443            resolved.metadata.description.as_deref(),
444            Some("Staging deploy only")
445        );
446        assert!(out.warnings.is_empty());
447    }
448
449    #[test]
450    fn project_local_wins_over_absent_global() {
451        let global = GlobalIndex::new();
452        let manifest = ProjectManifest {
453            required: vec![p("sandbox/example/token")],
454            secrets: BTreeMap::from([(
455                p("sandbox/example/token"),
456                IndexEntry {
457                    description: Some("Sandbox-only".to_owned()),
458                    pattern_id: Some("generic-bearer".to_owned()),
459                    ..IndexEntry::default()
460                },
461            )]),
462            ..ProjectManifest::default()
463        };
464
465        let out = merge_manifest(&global, &manifest).unwrap();
466        let r = out.secrets.get(&p("sandbox/example/token")).unwrap();
467        assert_eq!(r.origin, SecretOrigin::ProjectLocal);
468        assert_eq!(r.metadata.description.as_deref(), Some("Sandbox-only"));
469    }
470
471    #[test]
472    fn project_local_wins_over_global_when_both_present() {
473        // The spec is explicit: rule 1 (project-local) wins outright.
474        let mut global = GlobalIndex::new();
475        global.insert(
476            p("team/foo/token"),
477            IndexEntry {
478                description: Some("Global description".to_owned()),
479                ..IndexEntry::default()
480            },
481        );
482        let manifest = ProjectManifest {
483            required: vec![p("team/foo/token")],
484            secrets: BTreeMap::from([(
485                p("team/foo/token"),
486                IndexEntry {
487                    description: Some("Project-local description".to_owned()),
488                    ..IndexEntry::default()
489                },
490            )]),
491            ..ProjectManifest::default()
492        };
493
494        let out = merge_manifest(&global, &manifest).unwrap();
495        let r = out.secrets.get(&p("team/foo/token")).unwrap();
496        assert_eq!(r.origin, SecretOrigin::ProjectLocal);
497        assert_eq!(
498            r.metadata.description.as_deref(),
499            Some("Project-local description")
500        );
501    }
502
503    #[test]
504    fn optional_path_resolved_with_required_false() {
505        let mut global = GlobalIndex::new();
506        global.insert(p("personal/slack/notify-token"), IndexEntry::default());
507        let manifest = ProjectManifest {
508            optional: vec![p("personal/slack/notify-token")],
509            ..ProjectManifest::default()
510        };
511
512        let out = merge_manifest(&global, &manifest).unwrap();
513        let r = out.secrets.get(&p("personal/slack/notify-token")).unwrap();
514        assert!(!r.required);
515    }
516
517    #[test]
518    fn path_in_both_required_and_optional_resolves_as_required() {
519        let mut global = GlobalIndex::new();
520        global.insert(p("team/foo/token"), IndexEntry::default());
521        let manifest = ProjectManifest {
522            required: vec![p("team/foo/token")],
523            optional: vec![p("team/foo/token")],
524            ..ProjectManifest::default()
525        };
526
527        let out = merge_manifest(&global, &manifest).unwrap();
528        let r = out.secrets.get(&p("team/foo/token")).unwrap();
529        assert!(
530            r.required,
531            "required must win when path appears in both lists"
532        );
533    }
534
535    // -- Hard errors -----------------------------------------------------------
536
537    #[test]
538    fn unknown_required_path_errors() {
539        let global = GlobalIndex::new();
540        let manifest = ProjectManifest {
541            required: vec![p("team/gitlab/token-deploy")],
542            ..ProjectManifest::default()
543        };
544
545        let err = merge_manifest(&global, &manifest).unwrap_err();
546        match err {
547            MergeError::UnknownPath { path, role } => {
548                assert_eq!(path.as_str(), "team/gitlab/token-deploy");
549                assert_eq!(role, PathRole::Required);
550            }
551            other => panic!("expected UnknownPath, got {other:?}"),
552        }
553    }
554
555    #[test]
556    fn unknown_optional_path_errors_with_optional_role() {
557        let global = GlobalIndex::new();
558        let manifest = ProjectManifest {
559            optional: vec![p("personal/slack/notify-token")],
560            ..ProjectManifest::default()
561        };
562
563        let err = merge_manifest(&global, &manifest).unwrap_err();
564        assert!(matches!(
565            err,
566            MergeError::UnknownPath {
567                role: PathRole::Optional,
568                ..
569            }
570        ));
571    }
572
573    #[test]
574    fn override_on_project_local_path_errors() {
575        let manifest = ProjectManifest {
576            required: vec![p("sandbox/foo/token")],
577            secrets: BTreeMap::from([(p("sandbox/foo/token"), IndexEntry::default())]),
578            overrides: BTreeMap::from([(
579                p("sandbox/foo/token"),
580                OverrideEntry {
581                    gate: Some(Gate::Touchid),
582                    ..OverrideEntry::default()
583                },
584            )]),
585            ..ProjectManifest::default()
586        };
587
588        let err = merge_manifest(&GlobalIndex::new(), &manifest).unwrap_err();
589        match err {
590            MergeError::OverrideOnProjectLocal { path } => {
591                assert_eq!(path.as_str(), "sandbox/foo/token");
592            }
593            other => panic!("expected OverrideOnProjectLocal, got {other:?}"),
594        }
595    }
596
597    // -- Warnings --------------------------------------------------------------
598
599    #[test]
600    fn no_op_override_emits_warning_per_field() {
601        let mut global = GlobalIndex::new();
602        global.insert(
603            p("team/foo/token"),
604            IndexEntry {
605                default_gate: Some(Gate::Touchid),
606                rotate_every_days: Some(30),
607                description: Some("matches".to_owned()),
608                ..IndexEntry::default()
609            },
610        );
611        let manifest = ProjectManifest {
612            required: vec![p("team/foo/token")],
613            overrides: BTreeMap::from([(
614                p("team/foo/token"),
615                OverrideEntry {
616                    gate: Some(Gate::Touchid),
617                    rotate_every_days: Some(30),
618                    description: Some("matches".to_owned()),
619                    ..OverrideEntry::default()
620                },
621            )]),
622            ..ProjectManifest::default()
623        };
624
625        let out = merge_manifest(&global, &manifest).unwrap();
626        // Three warnings — one per redundant field.
627        assert_eq!(out.warnings.len(), 3);
628        let kinds: Vec<&MergeWarningKind> = out.warnings.iter().map(|w| &w.kind).collect();
629        assert!(kinds.iter().any(|k| matches!(
630            k,
631            MergeWarningKind::NoOpOverride {
632                field: OverrideField::Gate
633            }
634        )));
635        assert!(kinds.iter().any(|k| matches!(
636            k,
637            MergeWarningKind::NoOpOverride {
638                field: OverrideField::RotateEveryDays
639            }
640        )));
641        assert!(kinds.iter().any(|k| matches!(
642            k,
643            MergeWarningKind::NoOpOverride {
644                field: OverrideField::Description
645            }
646        )));
647    }
648
649    #[test]
650    fn override_for_undeclared_path_emits_warning() {
651        // Global has the path; manifest declares NOTHING but has an
652        // override for the path. Override is unused — warn.
653        let mut global = GlobalIndex::new();
654        global.insert(p("team/foo/token"), IndexEntry::default());
655        let manifest = ProjectManifest {
656            overrides: BTreeMap::from([(
657                p("team/foo/token"),
658                OverrideEntry {
659                    gate: Some(Gate::Touchid),
660                    ..OverrideEntry::default()
661                },
662            )]),
663            ..ProjectManifest::default()
664        };
665
666        let out = merge_manifest(&global, &manifest).unwrap();
667        assert!(out.secrets.is_empty());
668        assert_eq!(out.warnings.len(), 1);
669        assert!(matches!(
670            out.warnings[0].kind,
671            MergeWarningKind::OverrideForUndeclaredPath
672        ));
673        assert_eq!(out.warnings[0].path.as_str(), "team/foo/token");
674    }
675
676    #[test]
677    fn project_local_for_undeclared_path_emits_warning() {
678        let manifest = ProjectManifest {
679            secrets: BTreeMap::from([(
680                p("sandbox/orphan/token"),
681                IndexEntry {
682                    description: Some("orphan".to_owned()),
683                    ..IndexEntry::default()
684                },
685            )]),
686            ..ProjectManifest::default()
687        };
688
689        let out = merge_manifest(&GlobalIndex::new(), &manifest).unwrap();
690        assert!(out.secrets.is_empty());
691        assert_eq!(out.warnings.len(), 1);
692        assert!(matches!(
693            out.warnings[0].kind,
694            MergeWarningKind::ProjectLocalForUndeclaredPath
695        ));
696    }
697
698    // -- approve_on_use (P25) --------------------------------------------------
699
700    #[test]
701    fn override_applies_approve_on_use_over_global_index() {
702        use crate::index::ApproveOnUse;
703
704        let mut global = GlobalIndex::new();
705        global.insert(
706            p("team/gitlab/token-deploy"),
707            IndexEntry {
708                approve_on_use: Some(ApproveOnUse::Never),
709                ..IndexEntry::default()
710            },
711        );
712        let manifest = ProjectManifest {
713            required: vec![p("team/gitlab/token-deploy")],
714            overrides: BTreeMap::from([(
715                p("team/gitlab/token-deploy"),
716                OverrideEntry {
717                    approve_on_use: Some(ApproveOnUse::PerCall),
718                    ..OverrideEntry::default()
719                },
720            )]),
721            ..ProjectManifest::default()
722        };
723
724        let out = merge_manifest(&global, &manifest).unwrap();
725        let resolved = out.secrets.get(&p("team/gitlab/token-deploy")).unwrap();
726        assert_eq!(
727            resolved.metadata.approve_on_use,
728            Some(ApproveOnUse::PerCall)
729        );
730        match &resolved.origin {
731            SecretOrigin::Global { overrides_applied } => assert!(
732                overrides_applied.contains(&OverrideField::ApproveOnUse),
733                "expected ApproveOnUse in applied list: {overrides_applied:?}"
734            ),
735            other => panic!("expected Global origin, got {other:?}"),
736        }
737        assert!(out.warnings.is_empty());
738    }
739
740    #[test]
741    fn override_approve_on_use_matching_global_emits_noop_warning() {
742        use crate::index::ApproveOnUse;
743
744        let mut global = GlobalIndex::new();
745        global.insert(
746            p("team/foo/token"),
747            IndexEntry {
748                approve_on_use: Some(ApproveOnUse::Session),
749                ..IndexEntry::default()
750            },
751        );
752        let manifest = ProjectManifest {
753            required: vec![p("team/foo/token")],
754            overrides: BTreeMap::from([(
755                p("team/foo/token"),
756                OverrideEntry {
757                    approve_on_use: Some(ApproveOnUse::Session),
758                    ..OverrideEntry::default()
759                },
760            )]),
761            ..ProjectManifest::default()
762        };
763
764        let out = merge_manifest(&global, &manifest).unwrap();
765        assert_eq!(out.warnings.len(), 1);
766        assert!(matches!(
767            out.warnings[0].kind,
768            MergeWarningKind::NoOpOverride {
769                field: OverrideField::ApproveOnUse
770            }
771        ));
772    }
773
774    // -- Empty cases -----------------------------------------------------------
775
776    #[test]
777    fn empty_manifest_yields_empty_output() {
778        let global = GlobalIndex::new();
779        let manifest = ProjectManifest::new();
780        let out = merge_manifest(&global, &manifest).unwrap();
781        assert!(out.secrets.is_empty());
782        assert!(out.warnings.is_empty());
783    }
784
785    #[test]
786    fn empty_global_with_only_project_local_required_works() {
787        let manifest = ProjectManifest {
788            required: vec![p("sandbox/example/token")],
789            secrets: BTreeMap::from([(
790                p("sandbox/example/token"),
791                IndexEntry {
792                    description: Some("local".to_owned()),
793                    ..IndexEntry::default()
794                },
795            )]),
796            ..ProjectManifest::default()
797        };
798
799        let out = merge_manifest(&GlobalIndex::new(), &manifest).unwrap();
800        assert_eq!(out.secrets.len(), 1);
801        assert_eq!(
802            out.secrets.get(&p("sandbox/example/token")).unwrap().origin,
803            SecretOrigin::ProjectLocal
804        );
805    }
806
807    // -- Sorted iteration of output -------------------------------------------
808
809    #[test]
810    fn output_secrets_iter_sorted_by_path() {
811        let mut global = GlobalIndex::new();
812        global.insert(p("team/zoo/key"), IndexEntry::default());
813        global.insert(p("personal/foo/key"), IndexEntry::default());
814        global.insert(p("client-acme/bar/key"), IndexEntry::default());
815
816        let manifest = ProjectManifest {
817            required: vec![
818                p("team/zoo/key"),
819                p("personal/foo/key"),
820                p("client-acme/bar/key"),
821            ],
822            ..ProjectManifest::default()
823        };
824
825        let out = merge_manifest(&global, &manifest).unwrap();
826        let paths: Vec<&str> = out.secrets.keys().map(|p| p.as_str()).collect();
827        assert_eq!(
828            paths,
829            vec!["client-acme/bar/key", "personal/foo/key", "team/zoo/key"]
830        );
831    }
832}