1use anyhow::{Context, Result};
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20use std::fs;
21use std::path::{Path, PathBuf};
22
23use crate::fsutil;
24
25#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
27#[serde(default, deny_unknown_fields)]
28pub struct RepoTrust {
29 pub allow_project_commands: bool,
31
32 pub trusted_at: Option<DateTime<Utc>>,
34}
35
36impl RepoTrust {
37 pub fn is_trusted(&self) -> bool {
38 self.allow_project_commands
39 }
40}
41
42pub fn project_trust_path(repo_root: &Path) -> PathBuf {
44 repo_root.join(".ralph").join("trust.jsonc")
45}
46
47pub fn load_repo_trust(repo_root: &Path) -> Result<RepoTrust> {
49 let path = project_trust_path(repo_root);
50 if !path.exists() {
51 return Ok(RepoTrust::default());
52 }
53
54 let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
55 crate::jsonc::parse_jsonc::<RepoTrust>(&raw, &format!("trust {}", path.display()))
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum TrustFileInitStatus {
61 Created,
63 Updated,
65 Unchanged,
67}
68
69fn write_trust_file(path: &Path, trust: &RepoTrust) -> Result<()> {
70 let rendered = crate::jsonc::to_string_pretty(trust)
71 .with_context(|| format!("serialize {}", path.display()))?;
72 fsutil::write_atomic(path, rendered.as_bytes())
73 .with_context(|| format!("write {}", path.display()))
74}
75
76fn print_trust_warning() {
77 eprintln!(
78 "Warning: Trusting this repo allows project-local execution settings under .ralph/config.jsonc\n\
79 (runner binary overrides, plugin runners, agent.ci_gate, plugins.*) to take effect. Review that file first.\n\
80 Keep .ralph/trust.jsonc untracked; do not commit it."
81 );
82}
83
84pub fn initialize_repo_trust_file(repo_root: &Path) -> Result<TrustFileInitStatus> {
92 let ralph_dir = repo_root.join(".ralph");
93 fs::create_dir_all(&ralph_dir).with_context(|| format!("create {}", ralph_dir.display()))?;
94
95 let path = project_trust_path(repo_root);
96 if !path.exists() {
97 print_trust_warning();
98 let trust = RepoTrust {
99 allow_project_commands: true,
100 trusted_at: Some(Utc::now()),
101 };
102 write_trust_file(&path, &trust)?;
103 eprintln!(
104 "trust: created {} (do not commit this file)",
105 path.display()
106 );
107 return Ok(TrustFileInitStatus::Created);
108 }
109
110 let existing = load_repo_trust(repo_root)?;
111 if existing.allow_project_commands && existing.trusted_at.is_some() {
112 eprintln!(
113 "trust: unchanged ({} already allows project commands)",
114 path.display()
115 );
116 return Ok(TrustFileInitStatus::Unchanged);
117 }
118
119 print_trust_warning();
120 let trust = RepoTrust {
121 allow_project_commands: true,
122 trusted_at: Some(Utc::now()),
123 };
124 write_trust_file(&path, &trust)?;
125 eprintln!(
126 "trust: updated {} (do not commit this file)",
127 path.display()
128 );
129 Ok(TrustFileInitStatus::Updated)
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use tempfile::TempDir;
136
137 #[test]
138 fn project_trust_path_is_jsonc_only() {
139 let repo_root = TempDir::new().expect("temp dir");
140 assert_eq!(
141 project_trust_path(repo_root.path()),
142 repo_root.path().join(".ralph/trust.jsonc")
143 );
144 }
145
146 #[test]
147 fn load_repo_trust_ignores_legacy_json_file() {
148 let repo_root = TempDir::new().expect("temp dir");
149 let ralph_dir = repo_root.path().join(".ralph");
150 fs::create_dir_all(&ralph_dir).expect("create .ralph");
151 fs::write(
152 ralph_dir.join("trust.json"),
153 r#"{"allow_project_commands":true}"#,
154 )
155 .expect("write legacy trust file");
156
157 assert_eq!(
158 load_repo_trust(repo_root.path()).expect("load trust"),
159 RepoTrust::default()
160 );
161 }
162
163 #[test]
164 fn initialize_repo_trust_file_creates_valid_trust_roundtrip() {
165 let repo_root = TempDir::new().expect("temp dir");
166 let status = initialize_repo_trust_file(repo_root.path()).expect("init trust");
167 assert_eq!(status, TrustFileInitStatus::Created);
168 let loaded = load_repo_trust(repo_root.path()).expect("reload");
169 assert!(loaded.is_trusted());
170 assert!(loaded.trusted_at.is_some());
171 }
172
173 #[test]
174 fn initialize_repo_trust_file_idempotent_when_fully_trusted() {
175 let repo_root = TempDir::new().expect("temp dir");
176 assert_eq!(
177 initialize_repo_trust_file(repo_root.path()).expect("first"),
178 TrustFileInitStatus::Created
179 );
180 let first = fs::read_to_string(project_trust_path(repo_root.path())).expect("read");
181 assert_eq!(
182 initialize_repo_trust_file(repo_root.path()).expect("second"),
183 TrustFileInitStatus::Unchanged
184 );
185 let second = fs::read_to_string(project_trust_path(repo_root.path())).expect("read");
186 assert_eq!(first, second, "second run must not rewrite bytes");
187 }
188
189 #[test]
190 fn initialize_repo_trust_file_backfills_trusted_at() {
191 let repo_root = TempDir::new().expect("temp dir");
192 let ralph_dir = repo_root.path().join(".ralph");
193 fs::create_dir_all(&ralph_dir).expect("create .ralph");
194 fs::write(
195 project_trust_path(repo_root.path()),
196 r#"{"allow_project_commands":true}"#,
197 )
198 .expect("write trust");
199
200 assert_eq!(
201 initialize_repo_trust_file(repo_root.path()).expect("merge"),
202 TrustFileInitStatus::Updated
203 );
204 let loaded = load_repo_trust(repo_root.path()).expect("reload");
205 assert!(loaded.trusted_at.is_some());
206 }
207}