Skip to main content

ralph_workflow/phases/commit_logging/message_generation/
attempt_log.rs

1/// Per-attempt log for commit message generation.
2///
3/// Captures all details about a single attempt to generate a commit message,
4/// providing a complete audit trail for debugging.
5#[derive(Debug, Clone)]
6pub struct CommitAttemptLog {
7    /// Attempt number within this session
8    pub attempt_number: usize,
9    /// Agent being used (e.g., "claude", "glm")
10    pub agent: String,
11    /// Retry strategy (e.g., "initial", "`strict_json`")
12    pub strategy: String,
13    /// Timestamp when attempt started
14    pub timestamp: DateTime<Local>,
15    /// Size of the prompt in bytes
16    pub prompt_size_bytes: usize,
17    /// Size of the diff in bytes
18    pub diff_size_bytes: usize,
19    /// Whether the diff was pre-truncated
20    pub diff_was_truncated: bool,
21    /// Raw output from the agent (truncated if very large)
22    pub raw_output: Option<String>,
23    /// Extraction attempts with their results
24    pub extraction_attempts: Vec<ExtractionAttempt>,
25    /// Validation checks that were run
26    pub validation_checks: Vec<ValidationCheck>,
27    /// Final outcome of this attempt
28    pub outcome: Option<AttemptOutcome>,
29}
30
31impl CommitAttemptLog {
32    /// Create a new attempt log.
33    #[must_use]
34    pub fn new(attempt_number: usize, agent: &str, strategy: &str) -> Self {
35        Self {
36            attempt_number,
37            agent: agent.to_string(),
38            strategy: strategy.to_string(),
39            timestamp: Local::now(),
40            prompt_size_bytes: 0,
41            diff_size_bytes: 0,
42            diff_was_truncated: false,
43            raw_output: None,
44            extraction_attempts: Vec::new(),
45            validation_checks: Vec::new(),
46            outcome: None,
47        }
48    }
49
50    /// Create a new attempt log with basic info already set.
51    ///
52    /// This is the functional equivalent of calling `new()` followed by
53    /// `set_prompt_size()` and `set_diff_info()`, avoiding `let mut`.
54    #[must_use]
55    pub fn with_basics(
56        attempt_number: usize,
57        agent: &str,
58        strategy: &str,
59        prompt_size: usize,
60        diff_size: usize,
61        diff_was_truncated: bool,
62    ) -> Self {
63        Self {
64            attempt_number,
65            agent: agent.to_string(),
66            strategy: strategy.to_string(),
67            timestamp: Local::now(),
68            prompt_size_bytes: prompt_size,
69            diff_size_bytes: diff_size,
70            diff_was_truncated,
71            raw_output: None,
72            extraction_attempts: Vec::new(),
73            validation_checks: Vec::new(),
74            outcome: None,
75        }
76    }
77
78    /// Set the prompt size (consuming builder).
79    #[must_use]
80    pub fn with_prompt_size(mut self, size: usize) -> Self {
81        self.prompt_size_bytes = size;
82        self
83    }
84
85    /// Set the diff information (consuming builder).
86    #[must_use]
87    pub fn with_diff_info(mut self, size: usize, was_truncated: bool) -> Self {
88        self.diff_size_bytes = size;
89        self.diff_was_truncated = was_truncated;
90        self
91    }
92
93    /// Set the raw output from the agent (consuming builder).
94    ///
95    /// Truncates very large outputs to prevent log file bloat.
96    #[must_use]
97    pub fn with_raw_output(mut self, output: &str) -> Self {
98        const MAX_OUTPUT_SIZE: usize = 50_000;
99        self.raw_output = if output.len() > MAX_OUTPUT_SIZE {
100            Some(format!(
101                "{}\n\n[... truncated {} bytes ...]\n\n{}",
102                &output[..MAX_OUTPUT_SIZE / 2],
103                output.len() - MAX_OUTPUT_SIZE,
104                &output[output.len() - MAX_OUTPUT_SIZE / 2..]
105            ))
106        } else {
107            Some(output.to_string())
108        };
109        self
110    }
111
112    /// Record an extraction attempt (consuming builder).
113    #[must_use]
114    pub fn add_extraction_attempt(mut self, attempt: ExtractionAttempt) -> Self {
115        self.extraction_attempts = self
116            .extraction_attempts
117            .into_iter()
118            .chain([attempt])
119            .collect();
120        self
121    }
122
123    /// Record validation check results (consuming builder).
124    #[cfg(test)]
125    #[must_use]
126    pub fn with_validation_checks(mut self, checks: Vec<ValidationCheck>) -> Self {
127        self.validation_checks = checks;
128        self
129    }
130
131    /// Set the final outcome (consuming builder).
132    #[must_use]
133    pub fn with_outcome(mut self, outcome: AttemptOutcome) -> Self {
134        self.outcome = Some(outcome);
135        self
136    }
137
138    /// Write this log to a file using workspace abstraction.
139    ///
140    /// This is the architecture-conformant version that uses the workspace trait
141    /// instead of direct filesystem access.
142    ///
143    /// # Arguments
144    ///
145    /// * `log_dir` - Directory to write the log file to (relative to workspace)
146    /// * `workspace` - The workspace to use for filesystem operations
147    ///
148    /// # Returns
149    ///
150    /// Path to the written log file on success.
151    ///
152    /// # Errors
153    ///
154    /// Returns error if the operation fails.
155    pub fn write_to_workspace(
156        &self,
157        log_dir: &Path,
158        workspace: &dyn Workspace,
159    ) -> std::io::Result<PathBuf> {
160        // Create the log directory if needed
161        workspace.create_dir_all(log_dir)?;
162
163        // Generate filename
164        let filename = format!(
165            "attempt_{:03}_{}_{}_{}.log",
166            self.attempt_number,
167            sanitize_agent_name(&self.agent),
168            self.strategy.replace(' ', "_"),
169            self.timestamp.format("%Y%m%dT%H%M%S")
170        );
171        let log_path = log_dir.join(filename);
172
173        // Build content in memory
174        let content: String = [
175            self.header_as_string(),
176            self.context_as_string(),
177            self.raw_output_as_string(),
178            self.extraction_attempts_as_string(),
179            self.validation_as_string(),
180            self.outcome_as_string(),
181        ]
182        .into_iter()
183        .collect();
184
185        // Write using workspace
186        workspace.write(&log_path, &content)?;
187        Ok(log_path)
188    }
189
190    fn header_as_string(&self) -> String {
191        format!(
192            "========================================================================\n\
193             COMMIT GENERATION ATTEMPT LOG\n\
194             ========================================================================\n\
195             \n\
196             Attempt:   #{}\n\
197             Agent:     {}\n\
198             Strategy:  {}\n\
199             Timestamp: {}\n\
200             \n",
201            self.attempt_number,
202            self.agent,
203            self.strategy,
204            self.timestamp.format("%Y-%m-%d %H:%M:%S %Z")
205        )
206    }
207
208    fn context_as_string(&self) -> String {
209        format!(
210            "------------------------------------------------------------------------\n\
211             CONTEXT\n\
212             ---------------------------------------------------------------------------\n\
213             \n\
214             Prompt size: {} bytes ({} KB)\n\
215             Diff size:   {} bytes ({} KB)\n\
216             Diff truncated: {}\n\
217             \n",
218            self.prompt_size_bytes,
219            self.prompt_size_bytes / 1024,
220            self.diff_size_bytes,
221            self.diff_size_bytes / 1024,
222            if self.diff_was_truncated { "YES" } else { "NO" }
223        )
224    }
225
226    fn raw_output_as_string(&self) -> String {
227        let output_section = match &self.raw_output {
228            Some(output) => output.as_str(),
229            None => "[No output captured]",
230        };
231        format!(
232            "------------------------------------------------------------------------\n\
233             RAW AGENT OUTPUT\n\
234             ---------------------------------------------------------------------------\n\
235             \n\
236             {output_section}\n\
237             \n"
238        )
239    }
240
241    fn extraction_attempts_as_string(&self) -> String {
242        let attempts_section = if self.extraction_attempts.is_empty() {
243            "[No extraction attempts recorded]".to_string()
244        } else {
245            self.extraction_attempts
246                .iter()
247                .enumerate()
248                .map(|(i, attempt)| {
249                    let status = if attempt.success {
250                        "✓ SUCCESS"
251                    } else {
252                        "✗ FAILED"
253                    };
254                    format!(
255                        "{}. {} [{}]\n   Detail: {}\n",
256                        i + 1,
257                        attempt.method,
258                        status,
259                        attempt.detail
260                    )
261                })
262                .collect::<Vec<_>>()
263                .join("")
264        };
265        format!(
266            "------------------------------------------------------------------------\n\
267             EXTRACTION ATTEMPTS\n\
268             ---------------------------------------------------------------------------\n\
269             \n\
270             {attempts_section}\n\
271             \n"
272        )
273    }
274
275    fn validation_as_string(&self) -> String {
276        let validation_section = if self.validation_checks.is_empty() {
277            "[No validation checks recorded]".to_string()
278        } else {
279            self.validation_checks
280                .iter()
281                .map(|check| {
282                    let status = if check.passed { "✓ PASS" } else { "✗ FAIL" };
283                    if let Some(error) = &check.error {
284                        format!("  [{status}] {}: {error}", check.name)
285                    } else {
286                        format!("  [{status}] {}", check.name)
287                    }
288                })
289                .collect::<Vec<_>>()
290                .join("\n")
291        };
292        format!(
293            "------------------------------------------------------------------------\n\
294             VALIDATION RESULTS\n\
295             ---------------------------------------------------------------------------\n\
296             \n\
297             {validation_section}\n\
298             \n"
299        )
300    }
301
302    fn outcome_as_string(&self) -> String {
303        let outcome_section = match &self.outcome {
304            Some(outcome) => outcome.to_string(),
305            None => "[Outcome not recorded]".to_string(),
306        };
307        format!(
308            "------------------------------------------------------------------------\n\
309             OUTCOME\n\
310             ---------------------------------------------------------------------------\n\
311             \n\
312             {outcome_section}\n\
313             \n\
314             ========================================================================\n"
315        )
316    }
317}
318
319/// Sanitize agent name for use in filename.
320fn sanitize_agent_name(agent: &str) -> String {
321    agent
322        .chars()
323        .map(|c| if c.is_alphanumeric() { c } else { '_' })
324        .collect::<String>()
325        .chars()
326        .take(MAX_AGENT_NAME_LENGTH)
327        .collect()
328}