use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::ConfigError;
use crate::config::deprecation::normalize_template_vars;
use crate::path::format_path_for_display;
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Approvals {
#[serde(default)]
projects: BTreeMap<String, ApprovedProject>,
}
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
struct ApprovedProject {
#[serde(
default,
rename = "approved-commands",
skip_serializing_if = "Vec::is_empty"
)]
approved_commands: Vec<String>,
}
pub fn approvals_path() -> Option<PathBuf> {
if let Ok(path) = std::env::var("WORKTRUNK_APPROVALS_PATH") {
return Some(PathBuf::from(path));
}
#[cfg(test)]
panic!(
"WORKTRUNK_APPROVALS_PATH not set in test. Tests must use TestRepo which sets this \
automatically, or set it manually to an isolated test approvals path."
);
#[cfg(not(test))]
{
super::user::config_path().map(|p| p.with_file_name("approvals.toml"))
}
}
impl Approvals {
pub fn load() -> Result<Self, ConfigError> {
let Some(path) = approvals_path() else {
return Ok(Self::default());
};
Self::load_with_fallback(&path)
}
fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
let content = std::fs::read_to_string(path).map_err(|e| {
ConfigError(format!(
"Failed to read approvals file {}: {}",
format_path_for_display(path),
e
))
})?;
let approvals: Self = toml::from_str(&content).map_err(|e| {
ConfigError(format!(
"Failed to parse approvals file {}: {}",
format_path_for_display(path),
e
))
})?;
Ok(approvals)
}
fn load_with_fallback(path: &Path) -> Result<Self, ConfigError> {
if path.exists() {
return Self::load_from_file(path);
}
let config_path = path.with_file_name("config.toml");
if config_path.exists() {
return Self::load_from_config_file(&config_path);
}
Ok(Self::default())
}
}
impl Approvals {
pub fn save_to(&self, path: &Path) -> Result<(), ConfigError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| ConfigError(format!("Failed to create approvals directory: {e}")))?;
}
let mut doc = toml_edit::DocumentMut::new();
if !self.projects.is_empty() {
let mut projects_table = toml_edit::Table::new();
projects_table.set_implicit(true);
for (project_id, project_config) in &self.projects {
if project_config.approved_commands.is_empty() {
continue;
}
let mut project_table = toml_edit::Table::new();
let commands = format_multiline_array(project_config.approved_commands.iter());
project_table["approved-commands"] = toml_edit::value(commands);
projects_table[project_id] = toml_edit::Item::Table(project_table);
}
doc["projects"] = toml_edit::Item::Table(projects_table);
}
let output = doc.to_string();
let output = if output.trim().is_empty() {
String::new()
} else {
output
};
std::fs::write(path, output)
.map_err(|e| ConfigError(format!("Failed to write approvals file: {e}")))?;
Ok(())
}
}
impl Approvals {
pub fn is_command_approved(&self, project: &str, command: &str) -> bool {
let normalized_command = normalize_template_vars(command);
self.projects
.get(project)
.map(|p| {
p.approved_commands
.iter()
.any(|c| normalize_template_vars(c) == normalized_command)
})
.unwrap_or(false)
}
pub fn projects(&self) -> impl Iterator<Item = (&str, &[String])> {
self.projects
.iter()
.map(|(id, p)| (id.as_str(), p.approved_commands.as_slice()))
}
}
impl Approvals {
fn with_locked_mutation<F>(
&mut self,
approvals_path: Option<&Path>,
mutate: F,
) -> Result<(), ConfigError>
where
F: FnOnce(&mut Self) -> bool,
{
let path = match approvals_path {
Some(p) => p.to_path_buf(),
None => self::approvals_path().ok_or_else(|| {
ConfigError(
"Cannot determine approvals path. Set $HOME or $XDG_CONFIG_HOME".to_string(),
)
})?,
};
let _lock = super::user::mutation::acquire_config_lock(&path)?;
self.reload_from(&path)?;
if mutate(self) {
self.save_to(&path)?;
}
Ok(())
}
fn reload_from(&mut self, path: &Path) -> Result<(), ConfigError> {
let fresh = Self::load_with_fallback(path)?;
self.projects = fresh.projects;
Ok(())
}
pub(crate) fn load_from_config_file(config_path: &Path) -> Result<Self, ConfigError> {
let content = std::fs::read_to_string(config_path).map_err(|e| {
ConfigError(format!(
"Failed to read config file {}: {}",
format_path_for_display(config_path),
e
))
})?;
let config: super::UserConfig = toml::from_str(&content).map_err(|e| {
ConfigError(format!(
"Failed to parse config file {}: {}",
format_path_for_display(config_path),
e
))
})?;
let mut approvals = Approvals::default();
for (project_id, project_config) in &config.projects {
if !project_config.approved_commands.is_empty() {
approvals.projects.insert(
project_id.clone(),
ApprovedProject {
approved_commands: project_config.approved_commands.clone(),
},
);
}
}
Ok(approvals)
}
pub fn approve_command(
&mut self,
project: String,
command: String,
approvals_path: Option<&Path>,
) -> Result<(), ConfigError> {
self.with_locked_mutation(approvals_path, |approvals| {
if approvals.is_command_approved(&project, &command) {
return false;
}
approvals
.projects
.entry(project)
.or_default()
.approved_commands
.push(command);
true
})
}
pub fn approve_commands(
&mut self,
project: String,
commands: Vec<String>,
approvals_path: Option<&Path>,
) -> Result<(), ConfigError> {
self.with_locked_mutation(approvals_path, |approvals| {
let entry = approvals.projects.entry(project).or_default();
let mut changed = false;
for command in commands {
let normalized = normalize_template_vars(&command);
if !entry
.approved_commands
.iter()
.any(|c| normalize_template_vars(c) == normalized)
{
entry.approved_commands.push(command);
changed = true;
}
}
changed
})
}
pub fn revoke_project(
&mut self,
project: &str,
approvals_path: Option<&Path>,
) -> Result<(), ConfigError> {
let project = project.to_string();
self.with_locked_mutation(approvals_path, |approvals| {
let Some(project_config) = approvals.projects.get_mut(&project) else {
return false;
};
if project_config.approved_commands.is_empty() {
return false;
}
approvals.projects.remove(&project);
true
})
}
pub fn clear_all(&mut self, approvals_path: Option<&Path>) -> Result<(), ConfigError> {
self.with_locked_mutation(approvals_path, |approvals| {
if approvals.projects.is_empty() {
return false;
}
approvals.projects.clear();
true
})
}
}
fn format_multiline_array<'a>(items: impl Iterator<Item = &'a String>) -> toml_edit::Array {
let mut array: toml_edit::Array = items.collect();
for item in array.iter_mut() {
item.decor_mut().set_prefix("\n ");
}
array.set_trailing("\n");
array.set_trailing_comma(true);
array
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_dir() -> (TempDir, PathBuf) {
let temp_dir = TempDir::new().unwrap();
let approvals_path = temp_dir.path().join("approvals.toml");
(temp_dir, approvals_path)
}
fn load_from_path(path: &Path) -> Result<Approvals, ConfigError> {
if !path.exists() {
return Ok(Approvals::default());
}
Approvals::load_from_file(path)
}
#[test]
fn test_empty_approvals() {
let approvals = Approvals::default();
assert!(!approvals.is_command_approved("any/project", "any command"));
}
#[test]
fn test_approve_and_check() {
let (_temp_dir, path) = test_dir();
let mut approvals = Approvals::default();
approvals
.approve_command(
"github.com/user/repo".to_string(),
"npm install".to_string(),
Some(&path),
)
.unwrap();
assert!(approvals.is_command_approved("github.com/user/repo", "npm install"));
assert!(!approvals.is_command_approved("github.com/user/repo", "npm test"));
assert!(!approvals.is_command_approved("github.com/other/repo", "npm install"));
}
#[test]
fn test_approve_duplicate_is_noop() {
let (_temp_dir, path) = test_dir();
let mut approvals = Approvals::default();
approvals
.approve_command(
"github.com/user/repo".to_string(),
"npm install".to_string(),
Some(&path),
)
.unwrap();
approvals
.approve_command(
"github.com/user/repo".to_string(),
"npm install".to_string(),
Some(&path),
)
.unwrap();
let count = approvals
.projects
.get("github.com/user/repo")
.unwrap()
.approved_commands
.len();
assert_eq!(count, 1);
}
#[test]
fn test_approve_commands_batch() {
let (_temp_dir, path) = test_dir();
let mut approvals = Approvals::default();
approvals
.approve_commands(
"github.com/user/repo".to_string(),
vec!["npm install".to_string(), "npm test".to_string()],
Some(&path),
)
.unwrap();
assert!(approvals.is_command_approved("github.com/user/repo", "npm install"));
assert!(approvals.is_command_approved("github.com/user/repo", "npm test"));
}
#[test]
fn test_revoke_project() {
let (_temp_dir, path) = test_dir();
let mut approvals = Approvals::default();
approvals
.approve_commands(
"github.com/user/repo1".to_string(),
vec!["npm install".to_string(), "npm test".to_string()],
Some(&path),
)
.unwrap();
approvals
.approve_command(
"github.com/user/repo2".to_string(),
"cargo build".to_string(),
Some(&path),
)
.unwrap();
approvals
.revoke_project("github.com/user/repo1", Some(&path))
.unwrap();
assert!(!approvals.projects.contains_key("github.com/user/repo1"));
assert!(approvals.projects.contains_key("github.com/user/repo2"));
}
#[test]
fn test_clear_all() {
let (_temp_dir, path) = test_dir();
let mut approvals = Approvals::default();
approvals
.approve_command(
"github.com/user/repo1".to_string(),
"npm install".to_string(),
Some(&path),
)
.unwrap();
approvals
.approve_command(
"github.com/user/repo2".to_string(),
"cargo build".to_string(),
Some(&path),
)
.unwrap();
approvals.clear_all(Some(&path)).unwrap();
assert!(approvals.projects.is_empty());
}
#[test]
fn test_save_and_load() {
let (_temp_dir, path) = test_dir();
let mut approvals = Approvals::default();
approvals
.approve_commands(
"github.com/user/repo".to_string(),
vec!["npm install".to_string(), "npm test".to_string()],
Some(&path),
)
.unwrap();
let loaded = load_from_path(&path).unwrap();
assert!(loaded.is_command_approved("github.com/user/repo", "npm install"));
assert!(loaded.is_command_approved("github.com/user/repo", "npm test"));
}
#[test]
fn test_save_format() {
let (_temp_dir, path) = test_dir();
let mut approvals = Approvals::default();
approvals
.approve_commands(
"github.com/user/repo".to_string(),
vec!["npm install".to_string(), "npm test".to_string()],
Some(&path),
)
.unwrap();
let content = std::fs::read_to_string(&path).unwrap();
insta::assert_snapshot!(content, @r#"
[projects."github.com/user/repo"]
approved-commands = [
"npm install",
"npm test",
]
"#);
}
#[test]
fn test_normalized_approval_matching() {
let (_temp_dir, path) = test_dir();
let mut approvals = Approvals::default();
approvals
.approve_command(
"project".to_string(),
"echo {{ repo_root }}".to_string(),
Some(&path),
)
.unwrap();
assert!(approvals.is_command_approved("project", "echo {{ repo_path }}"));
}
#[test]
fn test_concurrent_approve_preserves_all() {
use std::sync::{Arc, Barrier};
use std::thread;
let (_temp_dir, path) = test_dir();
let num_threads = 10;
let barrier = Arc::new(Barrier::new(num_threads));
let config_path = Arc::new(path);
let handles: Vec<_> = (0..num_threads)
.map(|i| {
let barrier = Arc::clone(&barrier);
let config_path = Arc::clone(&config_path);
thread::spawn(move || {
let mut approvals = Approvals::default();
barrier.wait();
approvals
.approve_command(
"github.com/user/repo".to_string(),
format!("command_{i}"),
Some(&config_path),
)
.unwrap();
})
})
.collect();
for handle in handles {
handle.join().unwrap();
}
let content = std::fs::read_to_string(&*config_path).unwrap();
for i in 0..num_threads {
assert!(
content.contains(&format!("command_{i}")),
"command_{i} should be preserved"
);
}
}
#[test]
fn test_load_from_config_file() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[projects."github.com/user/repo"]
approved-commands = ["npm install", "npm test"]
[projects."github.com/other/repo"]
approved-commands = ["cargo build"]
"#,
)
.unwrap();
let approvals = Approvals::load_from_config_file(&config_path).unwrap();
assert!(approvals.is_command_approved("github.com/user/repo", "npm install"));
assert!(approvals.is_command_approved("github.com/user/repo", "npm test"));
assert!(approvals.is_command_approved("github.com/other/repo", "cargo build"));
}
#[test]
fn test_mutation_picks_up_config_toml_fallback() {
let temp_dir = TempDir::new().unwrap();
let approvals_path = temp_dir.path().join("approvals.toml");
let config_path = temp_dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[projects."github.com/user/repo"]
approved-commands = ["npm install"]
"#,
)
.unwrap();
let mut approvals = Approvals::default();
approvals
.approve_command(
"github.com/user/repo".to_string(),
"npm test".to_string(),
Some(&approvals_path),
)
.unwrap();
assert!(approvals.is_command_approved("github.com/user/repo", "npm install"));
assert!(approvals.is_command_approved("github.com/user/repo", "npm test"));
let content = std::fs::read_to_string(&approvals_path).unwrap();
assert!(content.contains("npm install"));
assert!(content.contains("npm test"));
}
#[test]
fn test_load_from_path_nonexistent() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("nonexistent.toml");
let approvals = load_from_path(&path).unwrap();
assert!(approvals.projects.is_empty());
}
#[test]
fn test_load_from_file_invalid_toml() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().join("approvals.toml");
std::fs::write(&path, "this is { not valid toml").unwrap();
let err = Approvals::load_from_file(&path).unwrap_err();
assert!(
err.to_string().contains("Failed to parse approvals file"),
"Expected parse error, got: {}",
err
);
}
#[test]
fn test_load_from_config_file_invalid_toml() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
std::fs::write(&config_path, "not { valid toml here").unwrap();
let err = Approvals::load_from_config_file(&config_path).unwrap_err();
assert!(
err.to_string().contains("Failed to parse config file"),
"Expected parse error, got: {}",
err
);
}
#[test]
fn test_revoke_project_nonexistent() {
let (_temp_dir, path) = test_dir();
let mut approvals = Approvals::default();
approvals
.approve_command("project-a".to_string(), "cmd1".to_string(), Some(&path))
.unwrap();
approvals
.revoke_project("nonexistent", Some(&path))
.unwrap();
assert!(approvals.is_command_approved("project-a", "cmd1"));
}
#[test]
fn test_clear_all_when_empty() {
let (_temp_dir, path) = test_dir();
let mut approvals = Approvals::default();
approvals.clear_all(Some(&path)).unwrap();
assert!(approvals.projects.is_empty());
}
#[test]
fn test_save_skips_empty_project() {
let (_temp_dir, path) = test_dir();
let mut approvals = Approvals::default();
approvals.projects.insert(
"empty-project".to_string(),
super::ApprovedProject {
approved_commands: vec![],
},
);
approvals.projects.insert(
"real-project".to_string(),
super::ApprovedProject {
approved_commands: vec!["cmd1".to_string()],
},
);
approvals.save_to(&path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(!content.contains("empty-project"));
assert!(content.contains("real-project"));
}
#[test]
fn test_revoke_project_with_empty_commands() {
let (_temp_dir, path) = test_dir();
let mut approvals = Approvals::default();
approvals
.approve_command("project-a".to_string(), "cmd1".to_string(), Some(&path))
.unwrap();
approvals
.projects
.get_mut("project-a")
.unwrap()
.approved_commands
.clear();
approvals.save_to(&path).unwrap();
approvals.revoke_project("project-a", Some(&path)).unwrap();
}
#[test]
fn test_projects_accessor() {
let (_temp_dir, path) = test_dir();
let mut approvals = Approvals::default();
approvals
.approve_command("project1".to_string(), "cmd1".to_string(), Some(&path))
.unwrap();
approvals
.approve_command("project2".to_string(), "cmd2".to_string(), Some(&path))
.unwrap();
let projects: Vec<_> = approvals.projects().collect();
assert_eq!(projects.len(), 2);
}
}