use serde::Deserialize;
use std::fs;
use std::path::{Path, PathBuf};
use crate::types::Decision;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RuleSource {
#[default]
BuiltIn,
Global,
Project,
}
#[derive(Debug, Deserialize)]
pub struct RulesManifestConfig {
pub version: u32,
#[serde(default = "default_decision")]
pub default_decision: Decision,
#[serde(default = "default_safety_level")]
pub safety_level: SafetyLevel,
#[serde(default)]
pub trust_level: TrustLevel,
pub include: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct PartialRulesConfig {
#[serde(default)]
pub allowlists: Allowlists,
#[serde(default)]
pub rules: Vec<Rule>,
}
#[derive(Debug, Clone)]
pub struct LoadedFileInfo {
pub name: String,
pub allowlist_count: usize,
pub rule_count: usize,
pub trust_counts: [usize; 3],
}
#[derive(Debug)]
pub struct LoadedConfig {
pub config: RulesConfig,
pub is_rules_manifest: bool,
pub rules_manifest_path: Option<PathBuf>,
pub files: Vec<LoadedFileInfo>,
}
fn is_rules_manifest(content: &str) -> bool {
content.lines().any(|line| {
let trimmed = line.trim();
trimmed == "include:" || trimmed.starts_with("include:")
})
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RulesConfig {
#[allow(dead_code)]
pub version: u32,
#[serde(default = "default_decision")]
pub default_decision: Decision,
#[serde(default = "default_safety_level")]
pub safety_level: SafetyLevel,
#[serde(default)]
pub trust_level: TrustLevel,
#[serde(default)]
pub allowlists: Allowlists,
#[serde(default)]
pub rules: Vec<Rule>,
}
pub(crate) fn default_decision() -> Decision {
Decision::Ask
}
pub(crate) fn default_safety_level() -> SafetyLevel {
SafetyLevel::High
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SafetyLevel {
Critical,
High,
Strict,
}
impl std::fmt::Display for SafetyLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.pad(match self {
SafetyLevel::Critical => "critical",
SafetyLevel::High => "high",
SafetyLevel::Strict => "strict",
})
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TrustLevel {
Minimal,
#[default]
Standard,
Full,
}
impl std::fmt::Display for TrustLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.pad(match self {
TrustLevel::Minimal => "minimal",
TrustLevel::Standard => "standard",
TrustLevel::Full => "full",
})
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct AllowlistEntry {
pub command: String,
pub trust: TrustLevel,
#[serde(default)]
pub reason: Option<String>,
#[serde(skip)]
pub source: RuleSource,
}
#[derive(Debug, Default, Deserialize)]
pub struct Allowlists {
#[serde(default)]
pub commands: Vec<AllowlistEntry>,
#[serde(default)]
#[allow(dead_code)]
pub paths: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct Rule {
pub id: String,
pub level: SafetyLevel,
#[serde(rename = "match")]
pub matcher: Matcher,
pub decision: Decision,
pub reason: String,
#[serde(skip)]
pub source: RuleSource,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum Matcher {
Pipeline {
pipeline: PipelineMatcher,
},
Redirect {
redirect: RedirectMatcher,
},
Command {
command: StringOrList,
#[serde(default)]
flags: Option<FlagsMatcher>,
#[serde(default)]
args: Option<ArgsMatcher>,
},
}
#[derive(Debug, Deserialize)]
pub struct PipelineMatcher {
pub stages: Vec<StageMatcher>,
}
#[derive(Debug, Deserialize)]
pub struct StageMatcher {
pub command: StringOrList,
#[serde(default)]
pub flags: Option<FlagsMatcher>,
}
#[derive(Debug, Deserialize)]
pub struct RedirectMatcher {
#[serde(default)]
pub op: Option<StringOrList>,
#[serde(default)]
pub target: Option<StringOrList>,
}
#[derive(Debug, Deserialize)]
pub struct FlagsMatcher {
#[serde(default)]
pub any_of: Vec<String>,
#[serde(default)]
pub all_of: Vec<String>,
#[serde(default)]
pub none_of: Vec<String>,
#[serde(default)]
pub starts_with: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct ArgsMatcher {
#[serde(default)]
pub any_of: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum StringOrList {
Single(String),
List { any_of: Vec<String> },
}
impl StringOrList {
pub fn matches(&self, value: &str) -> bool {
match self {
StringOrList::Single(s) => s == value,
StringOrList::List { any_of } => any_of.iter().any(|s| s == value),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProjectConfig {
pub override_safety_level: Option<SafetyLevel>,
pub override_trust_level: Option<TrustLevel>,
pub allowlists: Option<Allowlists>,
pub rules: Option<Vec<Rule>>,
pub disable_rules: Option<Vec<String>>,
}
pub fn find_project_root(cwd: &Path) -> Option<PathBuf> {
let mut current = cwd.to_path_buf();
loop {
if current.join(".git").exists() || current.join(".claude").is_dir() {
return Some(current);
}
if !current.pop() {
return None;
}
}
}
pub fn load_project_config(cwd: &Path) -> Result<Option<ProjectConfig>, String> {
let root = match find_project_root(cwd) {
Some(r) => r,
None => return Ok(None),
};
let config_path = root.join(".claude").join("longline.yaml");
let content = match fs::read_to_string(&config_path) {
Ok(c) => c,
Err(_) => return Ok(None), };
let config: ProjectConfig = serde_norway::from_str(&content)
.map_err(|e| format!("Failed to parse {}: {e}", config_path.display()))?;
Ok(Some(config))
}
pub fn load_global_config(home: &Path) -> Result<Option<ProjectConfig>, String> {
let config_path = home.join(".config").join("longline").join("longline.yaml");
let content = match fs::read_to_string(&config_path) {
Ok(c) => c,
Err(_) => return Ok(None),
};
let config: ProjectConfig = serde_norway::from_str(&content)
.map_err(|e| format!("Failed to parse {}: {e}", config_path.display()))?;
Ok(Some(config))
}
pub fn merge_project_config(config: &mut RulesConfig, project: ProjectConfig) {
merge_overlay_config(config, project, RuleSource::Project);
}
pub fn merge_overlay_config(config: &mut RulesConfig, overlay: ProjectConfig, source: RuleSource) {
if let Some(level) = overlay.override_safety_level {
config.safety_level = level;
}
if let Some(level) = overlay.override_trust_level {
config.trust_level = level;
}
if let Some(allowlists) = overlay.allowlists {
for mut entry in allowlists.commands {
entry.source = source;
config.allowlists.commands.push(entry);
}
config.allowlists.paths.extend(allowlists.paths);
}
if let Some(disable) = overlay.disable_rules {
config.rules.retain(|r| !disable.contains(&r.id));
}
if let Some(rules) = overlay.rules {
for mut rule in rules {
rule.source = source;
config.rules.push(rule);
}
}
}
pub fn load_rules(path: &Path) -> Result<RulesConfig, String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read rules file {}: {e}", path.display()))?;
if is_rules_manifest(&content) {
load_rules_manifest(path, &content)
} else {
let config: RulesConfig = serde_norway::from_str(&content)
.map_err(|e| format!("Failed to parse rules file {}: {e}", path.display()))?;
Ok(config)
}
}
fn load_rules_manifest(manifest_path: &Path, content: &str) -> Result<RulesConfig, String> {
let manifest: RulesManifestConfig = serde_norway::from_str(content)
.map_err(|e| format!("Failed to parse manifest {}: {e}", manifest_path.display()))?;
let manifest_dir = manifest_path.parent().unwrap_or(Path::new("."));
let mut merged_allowlists: Vec<AllowlistEntry> = Vec::new();
let mut merged_rules: Vec<Rule> = Vec::new();
for file_name in &manifest.include {
let file_path = manifest_dir.join(file_name);
let file_content = fs::read_to_string(&file_path)
.map_err(|e| format!("Failed to read included file {}: {e}", file_path.display()))?;
let partial: PartialRulesConfig = serde_norway::from_str(&file_content)
.map_err(|e| format!("Failed to parse included file {}: {e}", file_path.display()))?;
merged_allowlists.extend(partial.allowlists.commands);
merged_rules.extend(partial.rules);
}
Ok(RulesConfig {
version: manifest.version,
default_decision: manifest.default_decision,
safety_level: manifest.safety_level,
trust_level: manifest.trust_level,
allowlists: Allowlists {
commands: merged_allowlists,
paths: Vec::new(),
},
rules: merged_rules,
})
}
pub fn load_embedded_rules() -> Result<RulesConfig, String> {
let content = crate::embedded_rules::get("rules.yaml")
.ok_or_else(|| "Embedded rules.yaml not found".to_string())?;
let manifest: RulesManifestConfig = serde_norway::from_str(content)
.map_err(|e| format!("Failed to parse embedded rules.yaml: {e}"))?;
let mut merged_allowlists: Vec<AllowlistEntry> = Vec::new();
let mut merged_rules: Vec<Rule> = Vec::new();
for file_name in &manifest.include {
let file_content = crate::embedded_rules::get(file_name)
.ok_or_else(|| format!("Embedded file '{}' not found", file_name))?;
let partial: PartialRulesConfig = serde_norway::from_str(file_content)
.map_err(|e| format!("Failed to parse embedded file {}: {e}", file_name))?;
merged_allowlists.extend(partial.allowlists.commands);
merged_rules.extend(partial.rules);
}
Ok(RulesConfig {
version: manifest.version,
default_decision: manifest.default_decision,
safety_level: manifest.safety_level,
trust_level: manifest.trust_level,
allowlists: Allowlists {
commands: merged_allowlists,
paths: Vec::new(),
},
rules: merged_rules,
})
}
pub fn load_embedded_rules_with_info() -> Result<LoadedConfig, String> {
let content = crate::embedded_rules::get("rules.yaml")
.ok_or_else(|| "Embedded rules.yaml not found".to_string())?;
let manifest: RulesManifestConfig = serde_norway::from_str(content)
.map_err(|e| format!("Failed to parse embedded rules.yaml: {e}"))?;
let mut merged_allowlists: Vec<AllowlistEntry> = Vec::new();
let mut merged_rules: Vec<Rule> = Vec::new();
let mut files: Vec<LoadedFileInfo> = Vec::new();
for file_name in &manifest.include {
let file_content = crate::embedded_rules::get(file_name)
.ok_or_else(|| format!("Embedded file '{}' not found", file_name))?;
let partial: PartialRulesConfig = serde_norway::from_str(file_content)
.map_err(|e| format!("Failed to parse embedded file {}: {e}", file_name))?;
let trust_counts = compute_trust_counts(&partial.allowlists.commands);
files.push(LoadedFileInfo {
name: file_name.clone(),
allowlist_count: partial.allowlists.commands.len(),
rule_count: partial.rules.len(),
trust_counts,
});
merged_allowlists.extend(partial.allowlists.commands);
merged_rules.extend(partial.rules);
}
Ok(LoadedConfig {
config: RulesConfig {
version: manifest.version,
default_decision: manifest.default_decision,
safety_level: manifest.safety_level,
trust_level: manifest.trust_level,
allowlists: Allowlists {
commands: merged_allowlists,
paths: Vec::new(),
},
rules: merged_rules,
},
is_rules_manifest: true,
rules_manifest_path: None,
files,
})
}
fn compute_trust_counts(commands: &[AllowlistEntry]) -> [usize; 3] {
[
commands
.iter()
.filter(|e| e.trust == TrustLevel::Minimal)
.count(),
commands
.iter()
.filter(|e| e.trust == TrustLevel::Standard)
.count(),
commands
.iter()
.filter(|e| e.trust == TrustLevel::Full)
.count(),
]
}
pub fn load_rules_with_info(path: &Path) -> Result<LoadedConfig, String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read rules file {}: {e}", path.display()))?;
if is_rules_manifest(&content) {
load_rules_manifest_with_info(path, &content)
} else {
let config: RulesConfig = serde_norway::from_str(&content)
.map_err(|e| format!("Failed to parse rules file {}: {e}", path.display()))?;
let trust_counts = compute_trust_counts(&config.allowlists.commands);
Ok(LoadedConfig {
is_rules_manifest: false,
rules_manifest_path: None,
files: vec![LoadedFileInfo {
name: path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
allowlist_count: config.allowlists.commands.len(),
rule_count: config.rules.len(),
trust_counts,
}],
config,
})
}
}
fn load_rules_manifest_with_info(
manifest_path: &Path,
content: &str,
) -> Result<LoadedConfig, String> {
let manifest: RulesManifestConfig = serde_norway::from_str(content)
.map_err(|e| format!("Failed to parse manifest {}: {e}", manifest_path.display()))?;
let manifest_dir = manifest_path.parent().unwrap_or(Path::new("."));
let mut merged_allowlists: Vec<AllowlistEntry> = Vec::new();
let mut merged_rules: Vec<Rule> = Vec::new();
let mut files: Vec<LoadedFileInfo> = Vec::new();
for file_name in &manifest.include {
let file_path = manifest_dir.join(file_name);
let file_content = fs::read_to_string(&file_path)
.map_err(|e| format!("Failed to read included file {}: {e}", file_path.display()))?;
let partial: PartialRulesConfig = serde_norway::from_str(&file_content)
.map_err(|e| format!("Failed to parse included file {}: {e}", file_path.display()))?;
let trust_counts = compute_trust_counts(&partial.allowlists.commands);
files.push(LoadedFileInfo {
name: file_name.clone(),
allowlist_count: partial.allowlists.commands.len(),
rule_count: partial.rules.len(),
trust_counts,
});
merged_allowlists.extend(partial.allowlists.commands);
merged_rules.extend(partial.rules);
}
Ok(LoadedConfig {
config: RulesConfig {
version: manifest.version,
default_decision: manifest.default_decision,
safety_level: manifest.safety_level,
trust_level: manifest.trust_level,
allowlists: Allowlists {
commands: merged_allowlists,
paths: Vec::new(),
},
rules: merged_rules,
},
is_rules_manifest: true,
rules_manifest_path: Some(manifest_path.to_path_buf()),
files,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_rules_yaml() {
let yaml = r#"
version: 1
default_decision: ask
safety_level: high
allowlists:
commands:
- { command: "git status", trust: standard }
- { command: "git diff", trust: standard }
paths:
- "/tmp/**"
rules:
- id: rm-recursive-root
level: critical
match:
command: rm
flags:
any_of: ["-r", "-R", "--recursive"]
args:
any_of: ["/", "/*"]
decision: deny
reason: "Recursive delete targeting critical system path"
- id: curl-pipe-shell
level: critical
match:
pipeline:
stages:
- command:
any_of: [curl, wget]
- command:
any_of: [sh, bash, zsh]
decision: deny
reason: "Remote code execution: piping download to shell"
"#;
let config: RulesConfig = serde_norway::from_str(yaml).unwrap();
assert_eq!(config.version, 1);
assert_eq!(config.default_decision, Decision::Ask);
assert_eq!(config.safety_level, SafetyLevel::High);
assert_eq!(config.allowlists.commands.len(), 2);
assert_eq!(config.rules.len(), 2);
assert_eq!(config.rules[0].id, "rm-recursive-root");
assert_eq!(config.rules[0].decision, Decision::Deny);
assert_eq!(config.rules[1].id, "curl-pipe-shell");
}
#[test]
fn test_string_or_list_single() {
let s = StringOrList::Single("rm".to_string());
assert!(s.matches("rm"));
assert!(!s.matches("ls"));
}
#[test]
fn test_string_or_list_any_of() {
let s = StringOrList::List {
any_of: vec!["curl".into(), "wget".into()],
};
assert!(s.matches("curl"));
assert!(s.matches("wget"));
assert!(!s.matches("git"));
}
#[test]
fn test_safety_level_ordering() {
assert!(SafetyLevel::Strict > SafetyLevel::High);
assert!(SafetyLevel::High > SafetyLevel::Critical);
}
#[test]
fn test_minimal_rules_config() {
let yaml = "version: 1\nrules: []\n";
let config: RulesConfig = serde_norway::from_str(yaml).unwrap();
assert_eq!(config.default_decision, Decision::Ask);
assert_eq!(config.safety_level, SafetyLevel::High);
assert!(config.rules.is_empty());
}
#[test]
fn test_redirect_matcher_deserialization() {
let yaml = r#"
version: 1
rules:
- id: write-to-dev
level: critical
match:
redirect:
op:
any_of: [">", ">>"]
target:
any_of: ["/dev/sda", "/dev/nvme0n1"]
decision: deny
reason: "Writing directly to disk device"
"#;
let config: RulesConfig = serde_norway::from_str(yaml).unwrap();
assert_eq!(config.rules.len(), 1);
assert_eq!(config.rules[0].id, "write-to-dev");
}
#[test]
fn test_load_default_rules_file() {
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("rules")
.join("rules.yaml");
let config = load_rules(&path).expect("Default rules should parse");
assert!(
config.rules.len() > 30,
"Should have many rules, got {}",
config.rules.len()
);
assert_eq!(config.version, 1);
assert_eq!(config.default_decision, Decision::Ask);
}
#[test]
fn test_detect_rules_manifest_has_include() {
let yaml = r#"
version: 1
default_decision: ask
safety_level: high
include:
- core.yaml
- git.yaml
"#;
let config: RulesManifestConfig = serde_norway::from_str(yaml).unwrap();
assert_eq!(config.include.len(), 2);
assert_eq!(config.include[0], "core.yaml");
}
#[test]
fn test_partial_rules_config_no_version() {
let yaml = r#"
allowlists:
commands:
- { command: ls, trust: minimal }
- { command: cat, trust: minimal }
rules:
- id: test-rule
level: high
match:
command: rm
decision: ask
reason: "Test rule"
"#;
let config: PartialRulesConfig = serde_norway::from_str(yaml).unwrap();
assert_eq!(config.allowlists.commands.len(), 2);
assert_eq!(config.rules.len(), 1);
}
#[test]
fn test_is_rules_manifest_true_when_has_include() {
let yaml = r#"
version: 1
include:
- core.yaml
"#;
assert!(is_rules_manifest(yaml));
}
#[test]
fn test_is_rules_manifest_false_when_no_include() {
let yaml = r#"
version: 1
rules: []
"#;
assert!(!is_rules_manifest(yaml));
}
#[test]
fn test_load_rules_manifest_merges_files() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let manifest_path = dir.path().join("manifest.yaml");
std::fs::write(
&manifest_path,
r#"
version: 1
default_decision: ask
safety_level: high
include:
- core.yaml
- git.yaml
"#,
)
.unwrap();
std::fs::write(
dir.path().join("core.yaml"),
r#"
allowlists:
commands:
- { command: ls, trust: minimal }
- { command: cat, trust: minimal }
rules: []
"#,
)
.unwrap();
std::fs::write(
dir.path().join("git.yaml"),
r#"
allowlists:
commands:
- { command: "git status", trust: standard }
rules:
- id: git-force-push
level: high
match:
command: git
flags:
any_of: ["--force"]
decision: ask
reason: "Force push"
"#,
)
.unwrap();
let config = load_rules(&manifest_path).unwrap();
assert_eq!(config.allowlists.commands.len(), 3); assert_eq!(config.rules.len(), 1);
assert_eq!(config.rules[0].id, "git-force-push");
}
#[test]
fn test_load_rules_manifest_error_on_missing_file() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let manifest_path = dir.path().join("manifest.yaml");
std::fs::write(
&manifest_path,
r#"
version: 1
include:
- nonexistent.yaml
"#,
)
.unwrap();
let result = load_rules(&manifest_path);
assert!(result.is_err());
assert!(result.unwrap_err().contains("nonexistent.yaml"));
}
#[test]
fn test_load_rules_backwards_compat_monolithic() {
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("rules")
.join("rules.yaml");
let config = load_rules(&path).expect("Monolithic rules should still load");
assert!(config.rules.len() > 100, "Should have many rules");
assert!(
config.allowlists.commands.len() > 100,
"Should have many allowlist entries"
);
}
#[test]
fn test_loaded_config_tracks_files() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let manifest_path = dir.path().join("manifest.yaml");
std::fs::write(
&manifest_path,
r#"
version: 1
default_decision: ask
safety_level: high
include:
- core.yaml
"#,
)
.unwrap();
std::fs::write(
dir.path().join("core.yaml"),
r#"
allowlists:
commands:
- { command: ls, trust: minimal }
rules: []
"#,
)
.unwrap();
let loaded = load_rules_with_info(&manifest_path).unwrap();
assert!(loaded.is_rules_manifest);
assert_eq!(loaded.files.len(), 1);
assert_eq!(loaded.files[0].name, "core.yaml");
assert_eq!(loaded.files[0].allowlist_count, 1);
assert_eq!(loaded.files[0].rule_count, 0);
}
#[test]
fn test_project_config_all_fields() {
let yaml = r#"
override_safety_level: strict
allowlists:
commands:
- { command: "docker compose", trust: standard }
rules:
- id: project-allow-docker-build
level: high
match:
command: docker
args:
any_of: ["build"]
decision: allow
reason: "Docker builds are routine in this project"
disable_rules:
- npm-install
- npx-run
"#;
let config: ProjectConfig = serde_norway::from_str(yaml).unwrap();
assert_eq!(config.override_safety_level, Some(SafetyLevel::Strict));
assert_eq!(config.allowlists.as_ref().unwrap().commands.len(), 1);
assert_eq!(config.rules.as_ref().unwrap().len(), 1);
assert_eq!(config.disable_rules.as_ref().unwrap().len(), 2);
}
#[test]
fn test_project_config_empty() {
let yaml = "{}";
let config: ProjectConfig = serde_norway::from_str(yaml).unwrap();
assert!(config.override_safety_level.is_none());
assert!(config.allowlists.is_none());
assert!(config.rules.is_none());
assert!(config.disable_rules.is_none());
}
#[test]
fn test_project_config_partial() {
let yaml = "override_safety_level: critical\n";
let config: ProjectConfig = serde_norway::from_str(yaml).unwrap();
assert_eq!(config.override_safety_level, Some(SafetyLevel::Critical));
assert!(config.allowlists.is_none());
}
#[test]
fn test_find_project_root_with_git() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let sub = dir.path().join("a").join("b").join("c");
std::fs::create_dir_all(&sub).unwrap();
std::fs::create_dir_all(dir.path().join(".git")).unwrap();
let result = find_project_root(&sub);
assert_eq!(result, Some(dir.path().to_path_buf()));
}
#[test]
fn test_find_project_root_with_git_worktree_file() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let sub = dir.path().join("src");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(
dir.path().join(".git"),
"gitdir: /some/main/repo/.git/worktrees/branch",
)
.unwrap();
let result = find_project_root(&sub);
assert_eq!(result, Some(dir.path().to_path_buf()));
}
#[test]
fn test_find_project_root_with_claude_dir() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let sub = dir.path().join("src").join("deep");
std::fs::create_dir_all(&sub).unwrap();
std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
let result = find_project_root(&sub);
assert_eq!(result, Some(dir.path().to_path_buf()));
}
#[test]
fn test_find_project_root_none_when_missing() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let result = find_project_root(dir.path());
assert!(result.is_none());
}
#[test]
fn test_load_project_config_found() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".git")).unwrap();
let claude_dir = dir.path().join(".claude");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::write(
claude_dir.join("longline.yaml"),
"override_safety_level: strict\n",
)
.unwrap();
let result = load_project_config(dir.path()).unwrap();
assert!(result.is_some());
let config = result.unwrap();
assert_eq!(config.override_safety_level, Some(SafetyLevel::Strict));
}
#[test]
fn test_load_project_config_not_found() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".git")).unwrap();
let result = load_project_config(dir.path()).unwrap();
assert!(result.is_none());
}
#[test]
fn test_load_project_config_walks_up() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".git")).unwrap();
let claude_dir = dir.path().join(".claude");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::write(
claude_dir.join("longline.yaml"),
"override_safety_level: critical\n",
)
.unwrap();
let sub = dir.path().join("src").join("deep");
std::fs::create_dir_all(&sub).unwrap();
let result = load_project_config(&sub).unwrap();
assert!(result.is_some());
assert_eq!(
result.unwrap().override_safety_level,
Some(SafetyLevel::Critical)
);
}
#[test]
fn test_load_project_config_rejects_unknown_fields() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".git")).unwrap();
let claude_dir = dir.path().join(".claude");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::write(
claude_dir.join("longline.yaml"),
"allowlist:\n commands:\n - docker\n",
)
.unwrap();
let result = load_project_config(dir.path());
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.contains("unknown field"),
"Error should mention unknown field: {err}"
);
}
#[test]
fn test_merge_project_config_safety_level() {
let mut config = RulesConfig {
version: 1,
default_decision: Decision::Ask,
safety_level: SafetyLevel::High,
trust_level: TrustLevel::default(),
allowlists: Allowlists::default(),
rules: vec![],
};
let project = ProjectConfig {
override_safety_level: Some(SafetyLevel::Strict),
override_trust_level: None,
allowlists: None,
rules: None,
disable_rules: None,
};
merge_project_config(&mut config, project);
assert_eq!(config.safety_level, SafetyLevel::Strict);
}
#[test]
fn test_merge_project_config_allowlists() {
let mut config = RulesConfig {
version: 1,
default_decision: Decision::Ask,
safety_level: SafetyLevel::High,
trust_level: TrustLevel::default(),
allowlists: Allowlists {
commands: vec![AllowlistEntry {
command: "ls".to_string(),
trust: TrustLevel::Standard,
reason: None,
source: RuleSource::default(),
}],
paths: vec![],
},
rules: vec![],
};
let project = ProjectConfig {
override_safety_level: None,
override_trust_level: None,
allowlists: Some(Allowlists {
commands: vec![AllowlistEntry {
command: "docker compose".to_string(),
trust: TrustLevel::Standard,
reason: None,
source: RuleSource::default(),
}],
paths: vec![],
}),
rules: None,
disable_rules: None,
};
merge_project_config(&mut config, project);
assert_eq!(config.allowlists.commands.len(), 2);
assert!(config.allowlists.commands.iter().any(|e| e.command == "ls"));
assert!(config
.allowlists
.commands
.iter()
.any(|e| e.command == "docker compose"));
}
#[test]
fn test_merge_project_config_disable_rules() {
let mut config = RulesConfig {
version: 1,
default_decision: Decision::Ask,
safety_level: SafetyLevel::High,
trust_level: TrustLevel::default(),
allowlists: Allowlists::default(),
rules: vec![
Rule {
id: "rule-a".to_string(),
level: SafetyLevel::High,
matcher: Matcher::Command {
command: StringOrList::Single("rm".to_string()),
flags: None,
args: None,
},
decision: Decision::Deny,
reason: "test".to_string(),
source: RuleSource::default(),
},
Rule {
id: "rule-b".to_string(),
level: SafetyLevel::High,
matcher: Matcher::Command {
command: StringOrList::Single("chmod".to_string()),
flags: None,
args: None,
},
decision: Decision::Ask,
reason: "test".to_string(),
source: RuleSource::default(),
},
],
};
let project = ProjectConfig {
override_safety_level: None,
override_trust_level: None,
allowlists: None,
rules: None,
disable_rules: Some(vec!["rule-a".to_string()]),
};
merge_project_config(&mut config, project);
assert_eq!(config.rules.len(), 1);
assert_eq!(config.rules[0].id, "rule-b");
}
#[test]
fn test_merge_project_config_adds_rules() {
let mut config = RulesConfig {
version: 1,
default_decision: Decision::Ask,
safety_level: SafetyLevel::High,
trust_level: TrustLevel::default(),
allowlists: Allowlists::default(),
rules: vec![],
};
let project_yaml = r#"
rules:
- id: project-rule
level: high
match:
command: docker
decision: allow
reason: "Project allows docker"
"#;
let project: ProjectConfig = serde_norway::from_str(project_yaml).unwrap();
merge_project_config(&mut config, project);
assert_eq!(config.rules.len(), 1);
assert_eq!(config.rules[0].id, "project-rule");
}
#[test]
fn test_merge_project_config_empty_is_noop() {
let mut config = RulesConfig {
version: 1,
default_decision: Decision::Ask,
safety_level: SafetyLevel::High,
trust_level: TrustLevel::default(),
allowlists: Allowlists {
commands: vec![AllowlistEntry {
command: "ls".to_string(),
trust: TrustLevel::Standard,
reason: None,
source: RuleSource::default(),
}],
paths: vec![],
},
rules: vec![],
};
let project = ProjectConfig {
override_safety_level: None,
override_trust_level: None,
allowlists: None,
rules: None,
disable_rules: None,
};
merge_project_config(&mut config, project);
assert_eq!(config.safety_level, SafetyLevel::High);
assert_eq!(config.allowlists.commands.len(), 1);
}
#[test]
fn test_trust_level_ordering() {
assert!(TrustLevel::Minimal < TrustLevel::Standard);
assert!(TrustLevel::Standard < TrustLevel::Full);
assert!(TrustLevel::Minimal < TrustLevel::Full);
}
#[test]
fn test_trust_level_deserialize() {
let level: TrustLevel = serde_norway::from_str("minimal").unwrap();
assert_eq!(level, TrustLevel::Minimal);
let level: TrustLevel = serde_norway::from_str("standard").unwrap();
assert_eq!(level, TrustLevel::Standard);
let level: TrustLevel = serde_norway::from_str("full").unwrap();
assert_eq!(level, TrustLevel::Full);
}
#[test]
fn test_allowlist_entry_deserialize_tagged() {
let yaml = "command: \"git status\"\ntrust: minimal\n";
let entry: AllowlistEntry = serde_norway::from_str(yaml).unwrap();
assert_eq!(entry.command, "git status");
assert_eq!(entry.trust, TrustLevel::Minimal);
}
#[test]
fn test_allowlist_entry_deserialize_with_reason() {
let yaml = "command: \"git push\"\ntrust: full\nreason: \"Pushes local commits to a remote repository\"\n";
let entry: AllowlistEntry = serde_norway::from_str(yaml).unwrap();
assert_eq!(entry.command, "git push");
assert_eq!(entry.trust, TrustLevel::Full);
assert_eq!(
entry.reason.as_deref(),
Some("Pushes local commits to a remote repository")
);
}
#[test]
fn test_allowlist_entry_deserialize_without_reason() {
let yaml = "command: ls\ntrust: minimal\n";
let entry: AllowlistEntry = serde_norway::from_str(yaml).unwrap();
assert_eq!(entry.command, "ls");
assert_eq!(entry.reason, None);
}
#[test]
fn test_allowlist_entry_rejects_bare_string() {
let result: Result<AllowlistEntry, _> = serde_norway::from_str("ls");
assert!(result.is_err(), "Bare strings should be rejected");
}
#[test]
fn test_allowlist_entry_requires_trust_field() {
let yaml = "command: \"ls\"\n";
let result: Result<AllowlistEntry, _> = serde_norway::from_str(yaml);
assert!(result.is_err(), "Missing trust field should be rejected");
}
#[test]
fn test_rules_config_trust_level_default() {
let yaml = "version: 1\nallowlists:\n commands: []\nrules: []\n";
let config: RulesConfig = serde_norway::from_str(yaml).unwrap();
assert_eq!(config.trust_level, TrustLevel::Standard);
}
#[test]
fn test_rules_config_trust_level_explicit() {
let yaml = "version: 1\ntrust_level: minimal\nallowlists:\n commands: []\nrules: []\n";
let config: RulesConfig = serde_norway::from_str(yaml).unwrap();
assert_eq!(config.trust_level, TrustLevel::Minimal);
}
#[test]
fn test_load_embedded_rules_matches_disk() {
let disk_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("rules")
.join("rules.yaml");
let disk_config = load_rules(&disk_path).expect("Disk rules should load");
let embedded_config = load_embedded_rules().expect("Embedded rules should load");
assert_eq!(disk_config.version, embedded_config.version);
assert_eq!(
disk_config.default_decision,
embedded_config.default_decision
);
assert_eq!(disk_config.safety_level, embedded_config.safety_level);
assert_eq!(disk_config.trust_level, embedded_config.trust_level);
assert_eq!(
disk_config.allowlists.commands.len(),
embedded_config.allowlists.commands.len(),
"Allowlist count should match"
);
assert_eq!(
disk_config.rules.len(),
embedded_config.rules.len(),
"Rule count should match"
);
}
#[test]
fn test_load_embedded_rules_with_info() {
let loaded = load_embedded_rules_with_info().expect("Embedded rules with info should load");
assert!(loaded.is_rules_manifest);
assert!(
loaded.rules_manifest_path.is_none(),
"Embedded rules have no disk path"
);
assert!(
loaded.files.len() >= 11,
"Should have at least 11 included files"
);
}
#[test]
fn test_rule_source_default_is_builtin() {
let yaml = r#"
version: 1
rules:
- id: test-rule
level: high
match:
command: rm
decision: ask
reason: "Test"
"#;
let config: RulesConfig = serde_norway::from_str(yaml).unwrap();
assert_eq!(config.rules[0].source, RuleSource::BuiltIn);
}
#[test]
fn test_merge_project_config_tags_rules_as_project() {
let mut config = RulesConfig {
version: 1,
default_decision: Decision::Ask,
safety_level: SafetyLevel::High,
trust_level: TrustLevel::default(),
allowlists: Allowlists::default(),
rules: vec![],
};
let project_yaml = r#"
rules:
- id: project-rule
level: high
match:
command: docker
decision: allow
reason: "Project allows docker"
"#;
let project: ProjectConfig = serde_norway::from_str(project_yaml).unwrap();
merge_project_config(&mut config, project);
assert_eq!(config.rules[0].source, RuleSource::Project);
}
#[test]
fn test_merge_project_config_tags_allowlist_as_project() {
let mut config = RulesConfig {
version: 1,
default_decision: Decision::Ask,
safety_level: SafetyLevel::High,
trust_level: TrustLevel::default(),
allowlists: Allowlists {
commands: vec![AllowlistEntry {
command: "ls".to_string(),
trust: TrustLevel::Standard,
reason: None,
source: RuleSource::BuiltIn,
}],
paths: vec![],
},
rules: vec![],
};
let project = ProjectConfig {
override_safety_level: None,
override_trust_level: None,
allowlists: Some(Allowlists {
commands: vec![AllowlistEntry {
command: "docker compose".to_string(),
trust: TrustLevel::Standard,
reason: None,
source: RuleSource::default(),
}],
paths: vec![],
}),
rules: None,
disable_rules: None,
};
merge_project_config(&mut config, project);
assert_eq!(config.allowlists.commands[0].source, RuleSource::BuiltIn);
assert_eq!(config.allowlists.commands[1].source, RuleSource::Project);
}
#[test]
fn test_load_global_config_found() {
use tempfile::TempDir;
let home = TempDir::new().unwrap();
let config_dir = home.path().join(".config").join("longline");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("longline.yaml"),
"override_safety_level: strict\n",
)
.unwrap();
let result = load_global_config(home.path()).unwrap();
assert!(result.is_some());
let config = result.unwrap();
assert_eq!(config.override_safety_level, Some(SafetyLevel::Strict));
}
#[test]
fn test_load_global_config_not_found() {
use tempfile::TempDir;
let home = TempDir::new().unwrap();
let result = load_global_config(home.path()).unwrap();
assert!(result.is_none());
}
#[test]
fn test_load_global_config_rejects_unknown_fields() {
use tempfile::TempDir;
let home = TempDir::new().unwrap();
let config_dir = home.path().join(".config").join("longline");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(config_dir.join("longline.yaml"), "unknown_field: true\n").unwrap();
let result = load_global_config(home.path());
assert!(result.is_err());
}
#[test]
fn test_merge_overlay_config_tags_with_source() {
let mut config = RulesConfig {
version: 1,
default_decision: Decision::Ask,
safety_level: SafetyLevel::High,
trust_level: TrustLevel::default(),
allowlists: Allowlists::default(),
rules: vec![],
};
let overlay_yaml = r#"
allowlists:
commands:
- { command: mytool, trust: standard }
rules:
- id: overlay-rule
level: high
match:
command: docker
decision: allow
reason: "Test"
"#;
let overlay: ProjectConfig = serde_norway::from_str(overlay_yaml).unwrap();
merge_overlay_config(&mut config, overlay, RuleSource::Global);
assert_eq!(config.allowlists.commands[0].source, RuleSource::Global);
assert_eq!(config.rules[0].source, RuleSource::Global);
}
#[test]
fn test_stage_matcher_with_flags_deserialization() {
let yaml = r#"
version: 1
rules:
- id: pipe-with-flags
level: critical
match:
pipeline:
stages:
- command:
any_of: [curl, wget]
- command:
any_of: [python, python3]
flags:
none_of: ["-m", "-c"]
decision: deny
reason: "Test rule"
"#;
let config: RulesConfig = serde_norway::from_str(yaml).unwrap();
assert_eq!(config.rules.len(), 1);
assert_eq!(config.rules[0].id, "pipe-with-flags");
if let Matcher::Pipeline { ref pipeline } = config.rules[0].matcher {
assert!(pipeline.stages[0].flags.is_none());
let flags = pipeline.stages[1]
.flags
.as_ref()
.expect("second stage should have flags");
assert_eq!(flags.none_of, vec!["-m", "-c"]);
} else {
panic!("Expected pipeline matcher");
}
}
#[test]
fn test_stage_matcher_without_flags_still_works() {
let yaml = r#"
version: 1
rules:
- id: pipe-no-flags
level: critical
match:
pipeline:
stages:
- command:
any_of: [curl, wget]
- command:
any_of: [sh, bash]
decision: deny
reason: "Test rule"
"#;
let config: RulesConfig = serde_norway::from_str(yaml).unwrap();
assert_eq!(config.rules.len(), 1);
}
}