agpm_cli/core/operation_context.rs
1//! Operation-scoped context for cross-module state management.
2//!
3//! Provides a context object that flows through CLI operations, enabling
4//! features like warning deduplication without global state.
5//!
6//! # Overview
7//!
8//! The [`OperationContext`] is created at the start of CLI command execution
9//! and passed through the call chain to coordinate behavior across modules.
10//! This enables:
11//!
12//! - Warning deduplication during dependency resolution
13//! - Operation-scoped state without global variables
14//! - Better test isolation (each test creates its own context)
15//!
16//! # Example
17//!
18//! ```rust,no_run
19//! use agpm_cli::core::OperationContext;
20//! use std::path::Path;
21//!
22//! let ctx = OperationContext::new();
23//!
24//! // First warning for a file
25//! assert!(ctx.should_warn_file(Path::new("test.md")));
26//!
27//! // Subsequent warnings deduplicated
28//! assert!(!ctx.should_warn_file(Path::new("test.md")));
29//! ```
30
31use std::collections::HashSet;
32use std::path::Path;
33use std::sync::Mutex;
34
35/// Context for a single CLI operation (install, update, validate, etc.)
36///
37/// This context object flows through the operation call chain, providing
38/// operation-scoped state like warning deduplication. It uses [`Mutex`]
39/// for interior mutability to support async operations that may span
40/// multiple threads.
41///
42/// # Thread Safety
43///
44/// This struct is thread-safe and can be shared across async `.await` points.
45/// Uses [`Mutex`] for interior mutability, which has minimal overhead since
46/// contention is unlikely (warnings are infrequent).
47///
48/// # Lifecycle
49///
50/// 1. Created at the start of a CLI command (`InstallCommand::execute()`, etc.)
51/// 2. Passed down through resolver → extractor → parser call chain
52/// 3. Automatically cleaned up when the operation completes
53///
54/// # Examples
55///
56/// ```rust,no_run
57/// use agpm_cli::core::OperationContext;
58/// use std::path::Path;
59///
60/// // Create context at operation start
61/// let ctx = OperationContext::new();
62///
63/// // Use for warning deduplication
64/// if ctx.should_warn_file(Path::new("invalid.md")) {
65/// eprintln!("Warning: Invalid file");
66/// }
67///
68/// // Same file won't warn again
69/// assert!(!ctx.should_warn_file(Path::new("invalid.md")));
70/// ```
71#[derive(Debug, Default)]
72pub struct OperationContext {
73 /// Files that have already emitted warnings during this operation.
74 ///
75 /// Keys are normalized filenames (not full paths) to ensure consistent
76 /// deduplication across different path representations.
77 warned_files: Mutex<HashSet<String>>,
78}
79
80impl OperationContext {
81 /// Create a new operation context.
82 ///
83 /// Call this at the start of each CLI operation that needs state tracking.
84 ///
85 /// # Examples
86 ///
87 /// ```rust
88 /// use agpm_cli::core::OperationContext;
89 ///
90 /// let ctx = OperationContext::new();
91 /// ```
92 #[must_use]
93 pub fn new() -> Self {
94 Self::default()
95 }
96
97 /// Normalize a path to a deduplication key.
98 ///
99 /// Uses just the filename (not full path) for consistency across different
100 /// path representations (relative paths, worktrees, symlinks, etc.).
101 ///
102 /// Falls back to the full path string if filename extraction fails.
103 fn normalize_key(path: &Path) -> String {
104 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
105 // Use just the filename for consistency across path representations
106 filename.to_string()
107 } else {
108 // Fallback to full path if filename extraction fails
109 path.to_string_lossy().to_string()
110 }
111 }
112
113 /// Check if we should warn about a file and mark it as warned.
114 ///
115 /// Returns `true` if this is the first warning for this file in this operation,
116 /// `false` if we've already warned about it (deduplicated).
117 ///
118 /// # Deduplication Strategy
119 ///
120 /// Uses filename-based keys (not full paths) to handle different path
121 /// representations consistently:
122 /// - `/foo/bar/test.md` and `./bar/test.md` both key on `"test.md"`
123 /// - This works across worktrees, relative paths, and symlinks
124 ///
125 /// # Arguments
126 ///
127 /// * `path` - Path to the file being processed
128 ///
129 /// # Returns
130 ///
131 /// * `true` - First warning for this file, caller should display the warning
132 /// * `false` - Already warned about this file, caller should skip the warning
133 ///
134 /// # Examples
135 ///
136 /// ```rust
137 /// use agpm_cli::core::OperationContext;
138 /// use std::path::Path;
139 ///
140 /// let ctx = OperationContext::new();
141 /// let path = Path::new("agents/invalid.md");
142 ///
143 /// // First call returns true
144 /// assert!(ctx.should_warn_file(path));
145 ///
146 /// // Second call returns false (deduplicated)
147 /// assert!(!ctx.should_warn_file(path));
148 /// ```
149 pub fn should_warn_file(&self, path: &Path) -> bool {
150 let normalized_key = Self::normalize_key(path);
151 let mut warned = self.warned_files.lock().unwrap();
152 // insert() returns true if key was newly inserted, false if already present
153 warned.insert(normalized_key)
154 }
155
156 /// Check if a file has been warned about without modifying state.
157 ///
158 /// This is primarily useful for testing to verify deduplication behavior.
159 ///
160 /// # Arguments
161 ///
162 /// * `path` - Path to check
163 ///
164 /// # Returns
165 ///
166 /// * `true` - File has been warned about
167 /// * `false` - File has not been warned about
168 ///
169 /// # Examples
170 ///
171 /// ```rust
172 /// use agpm_cli::core::OperationContext;
173 /// use std::path::Path;
174 ///
175 /// let ctx = OperationContext::new();
176 /// let path = Path::new("test.md");
177 ///
178 /// assert!(!ctx.has_warned(path));
179 /// ctx.should_warn_file(path);
180 /// assert!(ctx.has_warned(path));
181 /// ```
182 #[cfg(test)]
183 pub fn has_warned(&self, path: &Path) -> bool {
184 let normalized_key = Self::normalize_key(path);
185 self.warned_files.lock().unwrap().contains(&normalized_key)
186 }
187
188 /// Get the count of unique files that have been warned about.
189 ///
190 /// Useful for diagnostics and testing.
191 ///
192 /// # Examples
193 ///
194 /// ```rust
195 /// use agpm_cli::core::OperationContext;
196 /// use std::path::Path;
197 ///
198 /// let ctx = OperationContext::new();
199 ///
200 /// assert_eq!(ctx.warning_count(), 0);
201 ///
202 /// ctx.should_warn_file(Path::new("file1.md"));
203 /// ctx.should_warn_file(Path::new("file2.md"));
204 ///
205 /// assert_eq!(ctx.warning_count(), 2);
206 /// ```
207 #[must_use]
208 pub fn warning_count(&self) -> usize {
209 self.warned_files.lock().unwrap().len()
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use std::path::PathBuf;
217
218 #[test]
219 fn test_new_context_is_empty() {
220 let ctx = OperationContext::new();
221 assert_eq!(ctx.warning_count(), 0);
222 }
223
224 #[test]
225 fn test_should_warn_first_time() {
226 let ctx = OperationContext::new();
227 let path = PathBuf::from("test.md");
228
229 assert!(ctx.should_warn_file(&path));
230 assert!(!ctx.should_warn_file(&path));
231 }
232
233 #[test]
234 fn test_same_filename_different_paths() {
235 let ctx = OperationContext::new();
236 let path1 = PathBuf::from("/foo/bar/test.md");
237 let path2 = PathBuf::from("/baz/qux/test.md");
238
239 // First path warns
240 assert!(ctx.should_warn_file(&path1));
241
242 // Second path with same filename is deduplicated
243 assert!(!ctx.should_warn_file(&path2));
244 }
245
246 #[test]
247 fn test_different_filenames() {
248 let ctx = OperationContext::new();
249 let path1 = PathBuf::from("file1.md");
250 let path2 = PathBuf::from("file2.md");
251
252 assert!(ctx.should_warn_file(&path1));
253 assert!(ctx.should_warn_file(&path2));
254 assert_eq!(ctx.warning_count(), 2);
255 }
256
257 #[test]
258 fn test_has_warned() {
259 let ctx = OperationContext::new();
260 let path = PathBuf::from("test.md");
261
262 assert!(!ctx.has_warned(&path));
263 ctx.should_warn_file(&path);
264 assert!(ctx.has_warned(&path));
265 }
266
267 #[test]
268 fn test_warning_count() {
269 let ctx = OperationContext::new();
270
271 assert_eq!(ctx.warning_count(), 0);
272
273 ctx.should_warn_file(&PathBuf::from("file1.md"));
274 assert_eq!(ctx.warning_count(), 1);
275
276 ctx.should_warn_file(&PathBuf::from("file2.md"));
277 assert_eq!(ctx.warning_count(), 2);
278
279 // Duplicate doesn't increase count
280 ctx.should_warn_file(&PathBuf::from("file1.md"));
281 assert_eq!(ctx.warning_count(), 2);
282 }
283
284 #[test]
285 fn test_multiple_contexts_are_isolated() {
286 let ctx1 = OperationContext::new();
287 let ctx2 = OperationContext::new();
288 let path = PathBuf::from("test.md");
289
290 // Each context tracks independently
291 assert!(ctx1.should_warn_file(&path));
292 assert!(ctx2.should_warn_file(&path));
293
294 // Within each context, deduplication works
295 assert!(!ctx1.should_warn_file(&path));
296 assert!(!ctx2.should_warn_file(&path));
297 }
298
299 #[test]
300 fn test_path_without_filename() {
301 let ctx = OperationContext::new();
302 // Path that has no filename component (edge case)
303 let path = PathBuf::from("/");
304
305 // Should still work (uses full path as fallback)
306 assert!(ctx.should_warn_file(&path));
307 assert!(!ctx.should_warn_file(&path));
308 }
309}