koda_core/
file_tracker.rs1use std::collections::HashSet;
15use std::path::{Path, PathBuf};
16
17use path_clean::PathClean;
18
19use crate::db::Database;
20
21pub(crate) fn resolve_file_path_from_args(
31 args: &serde_json::Value,
32 project_root: &Path,
33) -> Option<PathBuf> {
34 let path_str = args
35 .get("path")
36 .or(args.get("file_path"))
37 .and_then(|v| v.as_str())?;
38 let requested = Path::new(path_str);
39 let abs_path = if requested.is_absolute() {
40 requested.to_path_buf()
41 } else {
42 project_root.join(requested)
43 };
44 Some(abs_path.canonicalize().unwrap_or_else(|_| abs_path.clean()))
47}
48
49#[derive(Debug)]
54pub struct FileTracker {
55 owned: HashSet<PathBuf>,
57 session_id: String,
59 db: Database,
61}
62
63impl FileTracker {
64 pub async fn new(session_id: &str, db: Database) -> Self {
66 let owned = db.load_owned_files(session_id).await.unwrap_or_default();
67 Self {
68 owned,
69 session_id: session_id.to_string(),
70 db,
71 }
72 }
73
74 pub async fn track_created(&mut self, path: PathBuf) {
78 if self.owned.insert(path.clone())
79 && let Err(e) = self.db.insert_owned_file(&self.session_id, &path).await
80 {
81 tracing::warn!("file_tracker: failed to persist owned file {:?}: {e}", path);
82 }
83 }
84
85 pub async fn untrack(&mut self, path: &Path) {
87 if self.owned.remove(path)
88 && let Err(e) = self.db.delete_owned_file(&self.session_id, path).await
89 {
90 tracing::warn!("file_tracker: failed to remove owned file {:?}: {e}", path);
91 }
92 }
93
94 pub fn is_owned(&self, path: &Path) -> bool {
99 self.owned.contains(path)
100 }
101
102 #[cfg(test)]
104 pub fn len(&self) -> usize {
105 self.owned.len()
106 }
107
108 #[cfg(test)]
110 pub fn is_empty(&self) -> bool {
111 self.owned.is_empty()
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use crate::persistence::Persistence;
119 use tempfile::TempDir;
120
121 async fn test_db() -> (Database, TempDir) {
122 let dir = TempDir::new().unwrap();
123 let db = Database::open(&dir.path().join("test.db")).await.unwrap();
124 db.create_session("test-agent", dir.path()).await.unwrap();
125 (db, dir)
126 }
127
128 #[tokio::test]
129 async fn track_and_check_ownership() {
130 let (db, _dir) = test_db().await;
131 let mut tracker = FileTracker::new("test-session", db).await;
132
133 let path = PathBuf::from("/tmp/koda_test_file.md");
134 assert!(!tracker.is_owned(&path));
135
136 tracker.track_created(path.clone()).await;
137 assert!(tracker.is_owned(&path));
138 assert_eq!(tracker.len(), 1);
139 }
140
141 #[tokio::test]
142 async fn untrack_removes_ownership() {
143 let (db, _dir) = test_db().await;
144 let mut tracker = FileTracker::new("test-session", db).await;
145
146 let path = PathBuf::from("/tmp/koda_test_file.md");
147 tracker.track_created(path.clone()).await;
148 assert!(tracker.is_owned(&path));
149
150 tracker.untrack(&path).await;
151 assert!(!tracker.is_owned(&path));
152 assert_eq!(tracker.len(), 0);
153 }
154
155 #[tokio::test]
156 async fn persists_across_tracker_instances() {
157 let (db, _dir) = test_db().await;
158 let session_id = "persist-test";
159 let path = PathBuf::from("/tmp/koda_persist.md");
160
161 {
163 let mut tracker = FileTracker::new(session_id, db.clone()).await;
164 tracker.track_created(path.clone()).await;
165 }
166
167 {
169 let tracker = FileTracker::new(session_id, db.clone()).await;
170 assert!(tracker.is_owned(&path));
171 }
172 }
173
174 #[tokio::test]
175 async fn different_sessions_isolated() {
176 let (db, _dir) = test_db().await;
177 let path = PathBuf::from("/tmp/koda_isolated.md");
178
179 let mut tracker_a = FileTracker::new("session-a", db.clone()).await;
180 tracker_a.track_created(path.clone()).await;
181
182 let tracker_b = FileTracker::new("session-b", db).await;
183 assert!(!tracker_b.is_owned(&path));
184 }
185
186 #[tokio::test]
187 async fn duplicate_track_is_idempotent() {
188 let (db, _dir) = test_db().await;
189 let mut tracker = FileTracker::new("test-session", db).await;
190
191 let path = PathBuf::from("/tmp/koda_dup.md");
192 tracker.track_created(path.clone()).await;
193 tracker.track_created(path.clone()).await;
194 assert_eq!(tracker.len(), 1);
195 }
196
197 #[tokio::test]
198 async fn untrack_nonexistent_is_noop() {
199 let (db, _dir) = test_db().await;
200 let mut tracker = FileTracker::new("test-session", db).await;
201
202 let path = PathBuf::from("/tmp/never_tracked.md");
203 tracker.untrack(&path).await; assert_eq!(tracker.len(), 0);
205 }
206
207 #[tokio::test]
208 async fn absolute_path_ownership() {
209 let (db, _dir) = test_db().await;
210 let mut tracker = FileTracker::new("test-session", db).await;
211
212 let abs = PathBuf::from("/home/user/project/output.csv");
213 tracker.track_created(abs.clone()).await;
214 assert!(tracker.is_owned(&abs));
215
216 let abs2 = PathBuf::from("/home/user/project/output.csv");
218 assert!(tracker.is_owned(&abs2));
219
220 let other = PathBuf::from("/home/user/project/readme.md");
222 assert!(!tracker.is_owned(&other));
223 }
224
225 #[tokio::test]
226 async fn cross_session_resume_preserves_ownership_for_approval() {
227 use crate::trust::{ToolApproval, TrustMode, check_tool_with_tracker};
228
229 let (db, _dir) = test_db().await;
230 let session_id = "resume-test";
231 let root = Path::new("/home/user/project");
232 let owned_path = root.join("ephemeral.md");
233
234 {
236 let mut tracker = FileTracker::new(session_id, db.clone()).await;
237 tracker.track_created(owned_path.clone()).await;
238 assert!(tracker.is_owned(&owned_path));
239 }
240
241 {
244 let tracker = FileTracker::new(session_id, db.clone()).await;
245 assert!(
246 tracker.is_owned(&owned_path),
247 "Resumed tracker should still own the file"
248 );
249
250 let args = serde_json::json!({"path": "ephemeral.md"});
251 assert_eq!(
252 check_tool_with_tracker(
253 "Delete",
254 &args,
255 TrustMode::Auto,
256 Some(root),
257 Some(&tracker),
258 ),
259 ToolApproval::AutoApprove,
260 "Delete of resumed owned file should auto-approve"
261 );
262 }
263 }
264}