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}