use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const PROJECT_CONFIG_FILENAME: &str = ".trusty-search.yaml";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProjectConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exclude: Option<Vec<String>>,
#[serde(default = "crate::service::walker::default_extra_skip_dirs")]
pub extra_skip_dirs: Vec<String>,
#[serde(default = "default_data_file_max_bytes")]
pub data_file_max_bytes: Option<u64>,
}
fn default_data_file_max_bytes() -> Option<u64> {
Some(crate::service::walker::DEFAULT_DATA_FILE_MAX_BYTES)
}
impl Default for ProjectConfig {
fn default() -> Self {
Self {
name: None,
path: None,
exclude: None,
extra_skip_dirs: crate::service::walker::default_extra_skip_dirs(),
data_file_max_bytes: default_data_file_max_bytes(),
}
}
}
impl ProjectConfig {
pub fn load(dir: &Path) -> anyhow::Result<Option<Self>> {
let path = dir.join(PROJECT_CONFIG_FILENAME);
if !path.exists() {
return Ok(None);
}
let raw = std::fs::read_to_string(&path)
.map_err(|e| anyhow::anyhow!("failed to read {}: {e}", path.display()))?;
let cfg: Self = serde_yml::from_str(&raw)
.map_err(|e| anyhow::anyhow!("failed to parse {}: {e}", path.display()))?;
Ok(Some(cfg))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_load_absent() {
let tmp = tempdir().unwrap();
let res = ProjectConfig::load(tmp.path()).unwrap();
assert!(res.is_none(), "missing config file must return Ok(None)");
}
#[test]
fn test_load_name_only() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join(PROJECT_CONFIG_FILENAME), "name: foo\n").unwrap();
let cfg = ProjectConfig::load(tmp.path())
.unwrap()
.expect("config present");
assert_eq!(cfg.name.as_deref(), Some("foo"));
assert!(cfg.path.is_none());
assert!(cfg.exclude.is_none());
}
#[test]
fn test_load_full() {
let tmp = tempdir().unwrap();
fs::write(
tmp.path().join(PROJECT_CONFIG_FILENAME),
r#"
name: cto
path: app
exclude:
- data/
- docs/
- "*.db"
"#,
)
.unwrap();
let cfg = ProjectConfig::load(tmp.path())
.unwrap()
.expect("config present");
assert_eq!(cfg.name.as_deref(), Some("cto"));
assert_eq!(cfg.path, Some(PathBuf::from("app")));
assert_eq!(
cfg.exclude,
Some(vec![
"data/".to_string(),
"docs/".to_string(),
"*.db".to_string(),
])
);
}
#[test]
fn test_load_malformed() {
let tmp = tempdir().unwrap();
fs::write(
tmp.path().join(PROJECT_CONFIG_FILENAME),
"name: [unclosed\n : :",
)
.unwrap();
let res = ProjectConfig::load(tmp.path());
assert!(res.is_err(), "malformed yaml must return Err, not panic");
}
#[test]
fn data_file_hygiene_defaults_and_round_trips() {
let cfg = ProjectConfig::default();
assert!(cfg.extra_skip_dirs.contains(&"data".to_string()));
assert_eq!(cfg.extra_skip_dirs.len(), 6);
assert_eq!(cfg.data_file_max_bytes, Some(65_536));
let tmp = tempdir().unwrap();
fs::write(tmp.path().join(PROJECT_CONFIG_FILENAME), "name: foo\n").unwrap();
let cfg = ProjectConfig::load(tmp.path()).unwrap().unwrap();
assert!(
cfg.extra_skip_dirs.contains(&"snapshots".to_string()),
"missing field defaults to the targeted set: {:?}",
cfg.extra_skip_dirs
);
assert_eq!(cfg.data_file_max_bytes, Some(65_536));
let tmp = tempdir().unwrap();
fs::write(
tmp.path().join(PROJECT_CONFIG_FILENAME),
"extra_skip_dirs: [archive]\ndata_file_max_bytes: 8192\n",
)
.unwrap();
let cfg = ProjectConfig::load(tmp.path()).unwrap().unwrap();
assert_eq!(cfg.extra_skip_dirs, vec!["archive".to_string()]);
assert_eq!(cfg.data_file_max_bytes, Some(8192));
let serialized = serde_yml::to_string(&ProjectConfig::default()).unwrap();
assert!(
serialized.contains("extra_skip_dirs") && serialized.contains("data_file_max_bytes"),
"defaults must serialise: {serialized}"
);
}
}