use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::debug;
use crate::index::{Gate, IndexEntry};
use crate::secret_path::{PathError, SecretPath};
pub const MANIFEST_RELATIVE_PATH: &str = ".devboy/secrets.toml";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathRole {
Required,
Optional,
OverrideKey,
SecretKey,
}
impl fmt::Display for PathRole {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::Required => "required[]",
Self::Optional => "optional[]",
Self::OverrideKey => "[overrides.\"...\"]",
Self::SecretKey => "[secret.\"...\"]",
};
f.write_str(s)
}
}
#[derive(Debug, Error)]
pub enum ManifestError {
#[error("failed to read project manifest at {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse project manifest at {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("invalid path in manifest position {role}: {source}")]
Path {
role: PathRole,
#[source]
source: PathError,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OverrideEntry {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gate: Option<Gate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rotate_every_days: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approve_on_use: Option<crate::index::ApproveOnUse>,
}
impl OverrideEntry {
pub fn is_empty(&self) -> bool {
self.gate.is_none()
&& self.rotate_every_days.is_none()
&& self.description.is_none()
&& self.approve_on_use.is_none()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProjectManifest {
pub required: Vec<SecretPath>,
pub optional: Vec<SecretPath>,
pub overrides: BTreeMap<SecretPath, OverrideEntry>,
pub secrets: BTreeMap<SecretPath, IndexEntry>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct RawManifest {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
required: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
optional: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
overrides: BTreeMap<String, OverrideEntry>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
secret: BTreeMap<String, IndexEntry>,
}
impl ProjectManifest {
pub fn new() -> Self {
Self::default()
}
pub fn load() -> Result<Self, ManifestError> {
let cwd = std::env::current_dir().map_err(|e| ManifestError::Read {
path: PathBuf::from(MANIFEST_RELATIVE_PATH),
source: e,
})?;
Self::load_from_project_root(&cwd)
}
pub fn load_from_project_root(root: &Path) -> Result<Self, ManifestError> {
Self::load_from(&root.join(MANIFEST_RELATIVE_PATH))
}
pub fn load_from(path: &Path) -> Result<Self, ManifestError> {
if !path.exists() {
debug!(path = ?path, "project manifest not present, using empty");
return Ok(Self::new());
}
let body = fs::read_to_string(path).map_err(|e| ManifestError::Read {
path: path.to_path_buf(),
source: e,
})?;
Self::from_str_with_path(&body, path)
}
pub fn from_toml_str(body: &str) -> Result<Self, ManifestError> {
Self::from_str_with_path(body, Path::new("<inline>"))
}
fn from_str_with_path(body: &str, path: &Path) -> Result<Self, ManifestError> {
let raw: RawManifest = toml::from_str(body).map_err(|e| ManifestError::Parse {
path: path.to_path_buf(),
source: e,
})?;
let required = parse_path_list(&raw.required, PathRole::Required)?;
let optional = parse_path_list(&raw.optional, PathRole::Optional)?;
let overrides = parse_path_map(raw.overrides, PathRole::OverrideKey)?;
let secrets = parse_path_map(raw.secret, PathRole::SecretKey)?;
Ok(Self {
required,
optional,
overrides,
secrets,
})
}
pub fn is_empty(&self) -> bool {
self.required.is_empty()
&& self.optional.is_empty()
&& self.overrides.is_empty()
&& self.secrets.is_empty()
}
pub fn referenced_paths(&self) -> impl Iterator<Item = (&SecretPath, PathRole)> {
self.required
.iter()
.map(|p| (p, PathRole::Required))
.chain(self.optional.iter().map(|p| (p, PathRole::Optional)))
.chain(self.overrides.keys().map(|p| (p, PathRole::OverrideKey)))
.chain(self.secrets.keys().map(|p| (p, PathRole::SecretKey)))
}
}
fn parse_path_list(raw: &[String], role: PathRole) -> Result<Vec<SecretPath>, ManifestError> {
raw.iter()
.map(|s| SecretPath::parse(s).map_err(|source| ManifestError::Path { role, source }))
.collect()
}
fn parse_path_map<V>(
raw: BTreeMap<String, V>,
role: PathRole,
) -> Result<BTreeMap<SecretPath, V>, ManifestError> {
let mut out = BTreeMap::new();
for (k, v) in raw {
let p = SecretPath::parse(&k).map_err(|source| ManifestError::Path { role, source })?;
out.insert(p, v);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::index::RotationMethod;
fn fixture_full_manifest() -> &'static str {
r#"
required = [
"team/gitlab/token-deploy",
"personal/github/pat",
]
optional = ["personal/slack/notify-token"]
[overrides."team/gitlab/token-deploy"]
gate = "touchid"
rotate_every_days = 30
description = "Used by the staging deploy pipeline"
[secret."sandbox/example-provider/token"]
description = "Sandbox-only token recreated per developer"
retrieval_url = "https://example-provider.dev/account/api-tokens"
pattern_id = "generic-bearer"
rotation_method = "manual"
"#
}
#[test]
fn empty_string_yields_empty_manifest() {
let m = ProjectManifest::from_toml_str("").unwrap();
assert!(m.is_empty());
assert!(m.required.is_empty());
assert!(m.optional.is_empty());
assert!(m.overrides.is_empty());
assert!(m.secrets.is_empty());
}
#[test]
fn parses_full_manifest() {
let m = ProjectManifest::from_toml_str(fixture_full_manifest()).unwrap();
assert_eq!(m.required.len(), 2);
assert_eq!(m.required[0].as_str(), "team/gitlab/token-deploy");
assert_eq!(m.required[1].as_str(), "personal/github/pat");
assert_eq!(m.optional.len(), 1);
assert_eq!(m.optional[0].as_str(), "personal/slack/notify-token");
let override_path: SecretPath = "team/gitlab/token-deploy".parse().unwrap();
let ov = m.overrides.get(&override_path).expect("override present");
assert_eq!(ov.gate, Some(Gate::Touchid));
assert_eq!(ov.rotate_every_days, Some(30));
assert_eq!(
ov.description.as_deref(),
Some("Used by the staging deploy pipeline")
);
let local_path: SecretPath = "sandbox/example-provider/token".parse().unwrap();
let sec = m.secrets.get(&local_path).expect("project-local present");
assert_eq!(
sec.description.as_deref(),
Some("Sandbox-only token recreated per developer")
);
assert_eq!(
sec.retrieval_url.as_deref(),
Some("https://example-provider.dev/account/api-tokens")
);
assert_eq!(sec.pattern_id.as_deref(), Some("generic-bearer"));
assert_eq!(sec.rotation_method, Some(RotationMethod::Manual));
}
#[test]
fn parses_only_required() {
let m = ProjectManifest::from_toml_str(
r#"
required = ["team/gitlab/token-deploy"]
"#,
)
.unwrap();
assert_eq!(m.required.len(), 1);
assert!(m.optional.is_empty());
assert!(m.overrides.is_empty());
assert!(m.secrets.is_empty());
assert!(!m.is_empty());
}
#[test]
fn parses_only_overrides() {
let m = ProjectManifest::from_toml_str(
r#"
[overrides."team/gitlab/token-deploy"]
gate = "confirm"
"#,
)
.unwrap();
let p: SecretPath = "team/gitlab/token-deploy".parse().unwrap();
assert_eq!(m.overrides.get(&p).unwrap().gate, Some(Gate::Confirm));
}
#[test]
fn rejects_invalid_path_in_required() {
let err = ProjectManifest::from_toml_str(
r#"
required = ["gitlab.token"]
"#,
)
.unwrap_err();
match err {
ManifestError::Path { role, source } => {
assert_eq!(role, PathRole::Required);
assert!(matches!(source, PathError::TooFewSegments { .. }));
}
other => panic!("expected Path error, got {other:?}"),
}
}
#[test]
fn rejects_invalid_path_in_optional() {
let err = ProjectManifest::from_toml_str(
r#"
optional = ["Bad/Path/Format"]
"#,
)
.unwrap_err();
match err {
ManifestError::Path { role, source } => {
assert_eq!(role, PathRole::Optional);
assert!(matches!(source, PathError::BadSegment { .. }));
}
other => panic!("expected Path error, got {other:?}"),
}
}
#[test]
fn rejects_invalid_path_in_override_key() {
let err = ProjectManifest::from_toml_str(
r#"
[overrides."team/gitlab"]
gate = "auto"
"#,
)
.unwrap_err();
match err {
ManifestError::Path { role, source } => {
assert_eq!(role, PathRole::OverrideKey);
assert!(matches!(source, PathError::TooFewSegments { found: 2, .. }));
}
other => panic!("expected Path error, got {other:?}"),
}
}
#[test]
fn rejects_invalid_path_in_secret_key() {
let err = ProjectManifest::from_toml_str(
r#"
[secret."__sources/vault/token"]
description = "internal"
"#,
)
.unwrap_err();
match err {
ManifestError::Path { role, source } => {
assert_eq!(role, PathRole::SecretKey);
assert!(matches!(source, PathError::ReservedPrefix { .. }));
}
other => panic!("expected Path error, got {other:?}"),
}
}
#[test]
fn rejects_disallowed_override_field() {
for bad_field in [
"format_regex = \"^x$\"",
"retrieval_url = \"https://example.com\"",
"rotation_method = \"manual\"",
"pattern_id = \"x\"",
"expires_at = \"2026-08-01\"",
] {
let toml = format!("[overrides.\"team/gitlab/token-deploy\"]\n{bad_field}\n");
let err = ProjectManifest::from_toml_str(&toml).unwrap_err();
assert!(
matches!(err, ManifestError::Parse { .. }),
"field {bad_field:?} should be rejected at parse time, got {err:?}"
);
}
}
#[test]
fn override_entry_is_empty_when_all_fields_missing() {
let m = ProjectManifest::from_toml_str(
r#"
[overrides."team/gitlab/token-deploy"]
"#,
)
.unwrap();
let p: SecretPath = "team/gitlab/token-deploy".parse().unwrap();
let ov = m.overrides.get(&p).unwrap();
assert!(ov.is_empty());
}
#[test]
fn override_entry_is_not_empty_when_any_field_set() {
let m = ProjectManifest::from_toml_str(
r#"
[overrides."team/gitlab/token-deploy"]
gate = "auto"
"#,
)
.unwrap();
let p: SecretPath = "team/gitlab/token-deploy".parse().unwrap();
assert!(!m.overrides.get(&p).unwrap().is_empty());
}
#[test]
fn rejects_unknown_top_level_field() {
let err = ProjectManifest::from_toml_str(
r#"
required = []
extra_field = "something"
"#,
)
.unwrap_err();
assert!(matches!(err, ManifestError::Parse { .. }));
}
#[test]
fn load_from_returns_empty_when_file_missing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("missing.toml");
let m = ProjectManifest::load_from(&path).unwrap();
assert!(m.is_empty());
}
#[test]
fn load_from_real_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.toml");
std::fs::write(&path, fixture_full_manifest()).unwrap();
let m = ProjectManifest::load_from(&path).unwrap();
assert_eq!(m.required.len(), 2);
assert_eq!(m.secrets.len(), 1);
}
#[test]
fn load_from_project_root_resolves_dot_devboy_path() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".devboy")).unwrap();
let manifest_path = dir.path().join(MANIFEST_RELATIVE_PATH);
std::fs::write(&manifest_path, fixture_full_manifest()).unwrap();
let m = ProjectManifest::load_from_project_root(dir.path()).unwrap();
assert_eq!(m.required.len(), 2);
}
#[test]
fn load_from_returns_empty_when_dot_devboy_dir_missing() {
let dir = tempfile::tempdir().unwrap();
let m = ProjectManifest::load_from_project_root(dir.path()).unwrap();
assert!(m.is_empty());
}
#[test]
fn load_io_error_surfaces_path() {
let dir = tempfile::tempdir().unwrap();
let err = ProjectManifest::load_from(dir.path()).unwrap_err();
match err {
ManifestError::Read { path, .. } => assert_eq!(path, dir.path()),
other => panic!("expected Read, got {other:?}"),
}
}
#[test]
fn referenced_paths_yields_each_role() {
let m = ProjectManifest::from_toml_str(fixture_full_manifest()).unwrap();
let paths: Vec<(String, PathRole)> = m
.referenced_paths()
.map(|(p, r)| (p.as_str().to_owned(), r))
.collect();
assert_eq!(paths.len(), 5);
assert!(
paths
.iter()
.any(|(p, r)| p == "team/gitlab/token-deploy" && *r == PathRole::Required)
);
assert!(
paths
.iter()
.any(|(p, r)| p == "personal/github/pat" && *r == PathRole::Required)
);
assert!(
paths
.iter()
.any(|(p, r)| p == "personal/slack/notify-token" && *r == PathRole::Optional)
);
assert!(
paths
.iter()
.any(|(p, r)| p == "team/gitlab/token-deploy" && *r == PathRole::OverrideKey)
);
assert!(
paths
.iter()
.any(|(p, r)| p == "sandbox/example-provider/token" && *r == PathRole::SecretKey)
);
}
#[test]
fn path_role_display() {
assert_eq!(PathRole::Required.to_string(), "required[]");
assert_eq!(PathRole::Optional.to_string(), "optional[]");
assert_eq!(PathRole::OverrideKey.to_string(), "[overrides.\"...\"]");
assert_eq!(PathRole::SecretKey.to_string(), "[secret.\"...\"]");
}
}