#[derive(Debug, Clone)]
pub struct CommitAttemptLog {
pub attempt_number: usize,
pub agent: String,
pub strategy: String,
pub timestamp: DateTime<Local>,
pub prompt_size_bytes: usize,
pub diff_size_bytes: usize,
pub diff_was_truncated: bool,
pub raw_output: Option<String>,
pub extraction_attempts: Vec<ExtractionAttempt>,
pub validation_checks: Vec<ValidationCheck>,
pub outcome: Option<AttemptOutcome>,
}
impl CommitAttemptLog {
#[must_use]
pub fn new(attempt_number: usize, agent: &str, strategy: &str) -> Self {
Self {
attempt_number,
agent: agent.to_string(),
strategy: strategy.to_string(),
timestamp: Local::now(),
prompt_size_bytes: 0,
diff_size_bytes: 0,
diff_was_truncated: false,
raw_output: None,
extraction_attempts: Vec::new(),
validation_checks: Vec::new(),
outcome: None,
}
}
#[must_use]
pub fn with_basics(
attempt_number: usize,
agent: &str,
strategy: &str,
prompt_size: usize,
diff_size: usize,
diff_was_truncated: bool,
) -> Self {
Self {
attempt_number,
agent: agent.to_string(),
strategy: strategy.to_string(),
timestamp: Local::now(),
prompt_size_bytes: prompt_size,
diff_size_bytes: diff_size,
diff_was_truncated,
raw_output: None,
extraction_attempts: Vec::new(),
validation_checks: Vec::new(),
outcome: None,
}
}
#[must_use]
pub fn with_prompt_size(mut self, size: usize) -> Self {
self.prompt_size_bytes = size;
self
}
#[must_use]
pub fn with_diff_info(mut self, size: usize, was_truncated: bool) -> Self {
self.diff_size_bytes = size;
self.diff_was_truncated = was_truncated;
self
}
#[must_use]
pub fn with_raw_output(mut self, output: &str) -> Self {
const MAX_OUTPUT_SIZE: usize = 50_000;
self.raw_output = if output.len() > MAX_OUTPUT_SIZE {
Some(format!(
"{}\n\n[... truncated {} bytes ...]\n\n{}",
&output[..MAX_OUTPUT_SIZE / 2],
output.len() - MAX_OUTPUT_SIZE,
&output[output.len() - MAX_OUTPUT_SIZE / 2..]
))
} else {
Some(output.to_string())
};
self
}
#[must_use]
pub fn add_extraction_attempt(mut self, attempt: ExtractionAttempt) -> Self {
self.extraction_attempts = self
.extraction_attempts
.into_iter()
.chain([attempt])
.collect();
self
}
#[cfg(test)]
#[must_use]
pub fn with_validation_checks(mut self, checks: Vec<ValidationCheck>) -> Self {
self.validation_checks = checks;
self
}
#[must_use]
pub fn with_outcome(mut self, outcome: AttemptOutcome) -> Self {
self.outcome = Some(outcome);
self
}
pub fn write_to_workspace(
&self,
log_dir: &Path,
workspace: &dyn Workspace,
) -> std::io::Result<PathBuf> {
workspace.create_dir_all(log_dir)?;
let filename = format!(
"attempt_{:03}_{}_{}_{}.log",
self.attempt_number,
sanitize_agent_name(&self.agent),
self.strategy.replace(' ', "_"),
self.timestamp.format("%Y%m%dT%H%M%S")
);
let log_path = log_dir.join(filename);
let content: String = [
self.header_as_string(),
self.context_as_string(),
self.raw_output_as_string(),
self.extraction_attempts_as_string(),
self.validation_as_string(),
self.outcome_as_string(),
]
.into_iter()
.collect();
workspace.write(&log_path, &content)?;
Ok(log_path)
}
fn header_as_string(&self) -> String {
format!(
"========================================================================\n\
COMMIT GENERATION ATTEMPT LOG\n\
========================================================================\n\
\n\
Attempt: #{}\n\
Agent: {}\n\
Strategy: {}\n\
Timestamp: {}\n\
\n",
self.attempt_number,
self.agent,
self.strategy,
self.timestamp.format("%Y-%m-%d %H:%M:%S %Z")
)
}
fn context_as_string(&self) -> String {
format!(
"------------------------------------------------------------------------\n\
CONTEXT\n\
---------------------------------------------------------------------------\n\
\n\
Prompt size: {} bytes ({} KB)\n\
Diff size: {} bytes ({} KB)\n\
Diff truncated: {}\n\
\n",
self.prompt_size_bytes,
self.prompt_size_bytes / 1024,
self.diff_size_bytes,
self.diff_size_bytes / 1024,
if self.diff_was_truncated { "YES" } else { "NO" }
)
}
fn raw_output_as_string(&self) -> String {
let output_section = match &self.raw_output {
Some(output) => output.as_str(),
None => "[No output captured]",
};
format!(
"------------------------------------------------------------------------\n\
RAW AGENT OUTPUT\n\
---------------------------------------------------------------------------\n\
\n\
{output_section}\n\
\n"
)
}
fn extraction_attempts_as_string(&self) -> String {
let attempts_section = if self.extraction_attempts.is_empty() {
"[No extraction attempts recorded]".to_string()
} else {
self.extraction_attempts
.iter()
.enumerate()
.map(|(i, attempt)| {
let status = if attempt.success {
"✓ SUCCESS"
} else {
"✗ FAILED"
};
format!(
"{}. {} [{}]\n Detail: {}\n",
i + 1,
attempt.method,
status,
attempt.detail
)
})
.collect::<Vec<_>>()
.join("")
};
format!(
"------------------------------------------------------------------------\n\
EXTRACTION ATTEMPTS\n\
---------------------------------------------------------------------------\n\
\n\
{attempts_section}\n\
\n"
)
}
fn validation_as_string(&self) -> String {
let validation_section = if self.validation_checks.is_empty() {
"[No validation checks recorded]".to_string()
} else {
self.validation_checks
.iter()
.map(|check| {
let status = if check.passed { "✓ PASS" } else { "✗ FAIL" };
if let Some(error) = &check.error {
format!(" [{status}] {}: {error}", check.name)
} else {
format!(" [{status}] {}", check.name)
}
})
.collect::<Vec<_>>()
.join("\n")
};
format!(
"------------------------------------------------------------------------\n\
VALIDATION RESULTS\n\
---------------------------------------------------------------------------\n\
\n\
{validation_section}\n\
\n"
)
}
fn outcome_as_string(&self) -> String {
let outcome_section = match &self.outcome {
Some(outcome) => outcome.to_string(),
None => "[Outcome not recorded]".to_string(),
};
format!(
"------------------------------------------------------------------------\n\
OUTCOME\n\
---------------------------------------------------------------------------\n\
\n\
{outcome_section}\n\
\n\
========================================================================\n"
)
}
}
fn sanitize_agent_name(agent: &str) -> String {
agent
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect::<String>()
.chars()
.take(MAX_AGENT_NAME_LENGTH)
.collect()
}