forge-guardrails 0.1.2

Foundation types for an LLM-agent workflow framework
Documentation
const ERROR_PATTERNS: &[&str] = &[
    "error:",
    "fatal:",
    "hint:",
    "warning:",
    "conflict",
    "merge conflict",
    "cannot",
    "failed",
    "rejected",
    "untracked files",
];

pub(super) fn filter_git_output(command: &str, output: &str) -> String {
    let lower = output.to_ascii_lowercase();
    if ERROR_PATTERNS.iter().any(|pattern| lower.contains(pattern)) {
        return output.to_string();
    }

    if command.contains("status") {
        return filter_git_status(output);
    }
    if command.contains("diff") {
        return filter_git_diff(output);
    }
    if command.contains("log") {
        return filter_git_log(output, 10);
    }
    if output.len() > 10_000 {
        return format!(
            "{}\n... (truncated)",
            &output[..safe_char_boundary(output, 5000)]
        );
    }
    output.to_string()
}

fn filter_git_status(output: &str) -> String {
    let mut changed = Vec::new();
    let mut untracked = Vec::new();
    let mut in_untracked = false;

    for line in output.lines() {
        if line.starts_with("Untracked files:") {
            in_untracked = true;
            continue;
        }
        if in_untracked {
            if line.trim().is_empty() || line.starts_with('\t') {
                if !line.trim().is_empty() {
                    untracked.push(line.trim().to_string());
                }
                continue;
            }
            in_untracked = false;
        }

        let trimmed = line.trim();
        if is_short_status_line(line) || is_long_status_line(trimmed) {
            changed.push(trimmed.to_string());
        }
    }

    let mut result = String::new();
    if !changed.is_empty() {
        result.push_str(&changed.join("\n"));
    }
    if !untracked.is_empty() && untracked.len() <= 20 {
        if !result.is_empty() {
            result.push('\n');
        }
        result.push_str(&format!("Untracked: {}", untracked.join(", ")));
    } else if untracked.len() > 20 {
        if !result.is_empty() {
            result.push('\n');
        }
        result.push_str(&format!("Untracked: {} files", untracked.len()));
    }

    if result.is_empty() {
        "(clean)".to_string()
    } else {
        result
    }
}

fn is_short_status_line(line: &str) -> bool {
    let mut chars = line.chars();
    let Some(first) = chars.next() else {
        return false;
    };
    let Some(second) = chars.next() else {
        return false;
    };
    let Some(third) = chars.next() else {
        return false;
    };
    matches!(first, 'A' | 'M' | 'D' | 'R' | 'C' | 'U' | '?' | '!' | ' ')
        && matches!(second, 'A' | 'M' | 'D' | 'R' | 'C' | 'U' | '?' | '!' | ' ')
        && third.is_whitespace()
}

fn is_long_status_line(trimmed: &str) -> bool {
    ["modified:", "added:", "deleted:", "renamed:", "copied:"]
        .iter()
        .any(|prefix| trimmed.starts_with(prefix))
}

fn filter_git_diff(output: &str) -> String {
    let mut files = Vec::new();
    let mut hunks = Vec::new();

    for line in output.lines() {
        if let Some(rest) = line.strip_prefix("diff --git a/") {
            if let Some((file, _)) = rest.split_once(" b/") {
                files.push(file.to_string());
            }
        } else if line.starts_with("@@") {
            hunks.push(line.to_string());
        } else if line.starts_with('+') || line.starts_with('-') {
            if line.len() > 120 {
                hunks.push(format!("{}...", &line[..safe_char_boundary(line, 120)]));
            } else {
                hunks.push(line.to_string());
            }
        }
    }

    if files.is_empty() {
        return output.to_string();
    }

    let mut result = format!("Files changed: {}\n", files.len());
    result.push_str(
        &files
            .iter()
            .map(|file| format!("  {file}"))
            .collect::<Vec<_>>()
            .join("\n"),
    );
    if !hunks.is_empty() && hunks.len() <= 50 {
        result.push_str("\n\n");
        result.push_str(&hunks.join("\n"));
    } else if hunks.len() > 50 {
        result.push_str(&format!("\n\n... {} hunk headers (truncated)", hunks.len()));
    }
    result
}

fn filter_git_log(output: &str, max_entries: usize) -> String {
    let mut commits = Vec::new();
    let mut current: Option<String> = None;

    for line in output.lines() {
        if let Some(hash) = line.strip_prefix("commit ") {
            if let Some(commit) = current.take() {
                commits.push(commit);
            }
            if commits.len() >= max_entries {
                break;
            }
            current = Some(format!("commit {}", &hash[..hash.len().min(5)]));
        } else if line.starts_with("Author:") || line.starts_with("Date:") {
        } else if line.starts_with("Merge:") {
            if let Some(commit) = &mut current {
                commit.push_str(" (merge)");
            }
        } else if !line.trim().is_empty() {
            if let Some(commit) = &mut current {
                commit.push(' ');
                commit.push_str(line.trim());
            }
        }
    }
    if let Some(commit) = current {
        if commits.len() < max_entries {
            commits.push(commit);
        }
    }

    if commits.is_empty() {
        "(empty)".to_string()
    } else {
        commits.join("\n")
    }
}

fn safe_char_boundary(value: &str, limit: usize) -> usize {
    if value.len() <= limit {
        return value.len();
    }
    value
        .char_indices()
        .map(|(idx, _)| idx)
        .take_while(|idx| *idx <= limit)
        .last()
        .unwrap_or(0)
}