use anyhow::Result;
use serde::Serialize;
use tokio::sync::mpsc;
use std::borrow::Cow;
use crate::ai::adapter::{
CommentSeverity, Context, ReviewAction, RevieweeOutput, RevieweeStatus, ReviewerOutput,
};
use crate::ai::orchestrator::{Orchestrator, OrchestratorCommand, RallyEvent, RallyState};
use crate::ai::prompt_loader::{PromptLoader, PromptSource};
use crate::config::Config;
use crate::github;
use crate::config::SENSITIVE_AI_KEYS;
pub async fn run_headless_rally(
repo: &str,
pr_number: u32,
config: &Config,
working_dir: Option<&str>,
accept_local_overrides: bool,
) -> Result<bool> {
eprintln!("[Headless] Fetching PR #{} from {}...", pr_number, repo);
let pr = github::fetch_pr(repo, pr_number).await?;
let files = github::fetch_changed_files(repo, pr_number).await?;
let mut file_patches: Vec<(String, String)> = files
.iter()
.filter_map(|f| f.patch.as_ref().map(|p| (f.filename.clone(), p.clone())))
.collect();
let has_missing_patches = files.iter().any(|f| f.patch.is_none());
if has_missing_patches {
eprintln!("[Headless] Some files missing patches, fetching full PR diff...");
if let Ok(full_diff) = github::fetch_pr_diff(repo, pr_number).await {
let parsed = crate::diff::parse_unified_diff(&full_diff);
for (filename, patch) in &parsed {
if !file_patches.iter().any(|(f, _)| f == filename) {
file_patches.push((filename.clone(), patch.clone()));
}
}
}
}
let diff = file_patches
.iter()
.map(|(_, p)| p.as_str())
.collect::<Vec<_>>()
.join("\n");
let context = Context {
repo: repo.to_string(),
pr_number,
pr_title: pr.title.clone(),
pr_body: pr.body.clone(),
diff,
working_dir: working_dir.map(|s| s.to_string()),
head_sha: pr.head.sha.clone(),
base_branch: pr.base.ref_name.clone(),
external_comments: Vec::new(),
local_mode: false,
file_patches,
};
run_headless_with_context(repo, pr_number, config, context, accept_local_overrides).await
}
pub async fn run_headless_rally_local(
repo: &str,
config: &Config,
working_dir: Option<&str>,
accept_local_overrides: bool,
) -> Result<bool> {
eprintln!("[Headless] Running local diff rally...");
let wd = working_dir.map(|s| s.to_string()).or_else(|| {
std::env::current_dir()
.ok()
.map(|p| p.to_string_lossy().to_string())
});
let dir = wd.as_deref().unwrap_or(".");
let base_branch = detect_local_base_branch(Some(dir)).unwrap_or_else(|| "main".to_string());
let diff_output = tokio::process::Command::new("git")
.args(["diff", "HEAD"])
.current_dir(dir)
.output()
.await?;
if !diff_output.status.success() {
let stderr = String::from_utf8_lossy(&diff_output.stderr);
anyhow::bail!(
"git diff HEAD failed (exit {}): {}",
diff_output.status,
stderr.trim()
);
}
let mut diff = String::from_utf8_lossy(&diff_output.stdout).to_string();
let untracked_diff = collect_untracked_diff(dir).await;
if !untracked_diff.is_empty() {
if !diff.is_empty() && !diff.ends_with('\n') {
diff.push('\n');
}
diff.push_str(&untracked_diff);
}
if diff.trim().is_empty() {
eprintln!(
"[Headless] No uncommitted changes, trying diff against origin/{}...",
base_branch
);
let fallback_output = tokio::process::Command::new("git")
.args(["diff", &format!("origin/{}...HEAD", base_branch)])
.current_dir(dir)
.output()
.await?;
if fallback_output.status.success() {
diff = String::from_utf8_lossy(&fallback_output.stdout).to_string();
} else {
let stderr = String::from_utf8_lossy(&fallback_output.stderr);
eprintln!("[Headless] Fallback diff also failed: {}", stderr.trim());
}
}
if diff.trim().is_empty() {
anyhow::bail!(
"No changes detected: both git diff HEAD and git diff origin/{}...HEAD returned empty",
base_branch
);
}
let head_sha = tokio::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.await
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
let context = Context {
repo: repo.to_string(),
pr_number: 0,
pr_title: "Local diff".to_string(),
pr_body: None,
diff,
working_dir: wd,
head_sha,
base_branch,
external_comments: Vec::new(),
local_mode: true,
file_patches: Vec::new(),
};
run_headless_with_context(repo, 0, config, context, accept_local_overrides).await
}
fn collect_sensitive_overrides(config: &Config) -> Vec<Cow<'static, str>> {
let mut sensitive_overrides: Vec<Cow<'static, str>> = SENSITIVE_AI_KEYS
.iter()
.filter(|key| config.local_overrides.contains(**key))
.map(|s| Cow::Borrowed(*s))
.collect();
let prompt_loader = PromptLoader::new(&config.ai, &config.project_root);
for (filename, source) in prompt_loader.resolve_all_sources() {
if let PromptSource::Local(path) = source {
sensitive_overrides.push(Cow::Owned(format!(
"local prompt: {} ({})",
filename,
path.display()
)));
}
}
sensitive_overrides
}
async fn run_headless_with_context(
repo: &str,
pr_number: u32,
config: &Config,
context: Context,
accept_local_overrides: bool,
) -> Result<bool> {
let sensitive_overrides = collect_sensitive_overrides(config);
let prompt_loader = PromptLoader::new(&config.ai, &config.project_root);
if !sensitive_overrides.is_empty() && !accept_local_overrides {
eprintln!(
"[Headless] WARNING: Local .octorus/ overrides detected that affect AI behavior:"
);
for key in &sensitive_overrides {
eprintln!(" - {}", key);
}
eprintln!(
"[Headless] Use --accept-local-overrides to explicitly allow these overrides."
);
anyhow::bail!(
"Refusing to run AI Rally with local overrides: {}. \
Use --accept-local-overrides to bypass this check.",
sensitive_overrides
.iter()
.map(|s| s.as_ref())
.collect::<Vec<&str>>()
.join(", ")
);
}
let (event_tx, mut event_rx) = mpsc::channel(100);
let (cmd_tx, cmd_rx) = mpsc::channel(10);
let local_mode = context.local_mode;
let mut orchestrator = Orchestrator::new(
repo,
pr_number,
config.ai.clone(),
event_tx,
Some(cmd_rx),
prompt_loader,
)?;
orchestrator.set_context(context);
let orchestrator_handle = tokio::spawn(async move { orchestrator.run().await });
let outcome = run_headless_event_loop(&mut event_rx, &cmd_tx, local_mode).await;
let _ = orchestrator_handle.await;
match &outcome.result {
HeadlessResult::Approved(_) => {
eprintln!("\n[Headless] Rally completed: Approved");
}
HeadlessResult::NotApproved(reason) => {
eprintln!("\n[Headless] Rally completed: {}", reason);
}
HeadlessResult::Error(msg) => {
eprintln!("\n[Headless] Rally error: {}", msg);
}
}
let json_output = build_json_output(&outcome);
write_json_stdout(&json_output);
Ok(matches!(outcome.result, HeadlessResult::Approved(_)))
}
enum HeadlessResult {
Approved(String),
NotApproved(String),
Error(String),
}
struct HeadlessOutcome {
result: HeadlessResult,
iterations: u32,
last_review: Option<ReviewerOutput>,
last_fix: Option<RevieweeOutput>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
enum HeadlessResultKind {
Approved,
NotApproved,
Error,
}
#[derive(Debug, Serialize)]
struct HeadlessJsonOutput {
result: HeadlessResultKind,
iterations: u32,
summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
last_review: Option<ReviewerOutput>,
#[serde(skip_serializing_if = "Option::is_none")]
last_fix: Option<RevieweeOutput>,
}
async fn run_headless_event_loop(
event_rx: &mut mpsc::Receiver<RallyEvent>,
cmd_tx: &mpsc::Sender<OrchestratorCommand>,
local_mode: bool,
) -> HeadlessOutcome {
let mut last_error: Option<String> = None;
let mut current_iteration: u32 = 0;
let mut last_review: Option<ReviewerOutput> = None;
let mut last_fix: Option<RevieweeOutput> = None;
while let Some(event) = event_rx.recv().await {
match event {
RallyEvent::IterationStarted(n) => {
current_iteration = n;
eprintln!("\n=== Iteration {} ===", n);
}
RallyEvent::StateChanged(state) => match state {
RallyState::ReviewerReviewing => {
eprintln!("[Reviewer] Reviewing...");
}
RallyState::RevieweeFix => {
eprintln!("[Reviewee] Fixing...");
}
RallyState::Completed => {
}
RallyState::Aborted => {
return HeadlessOutcome {
result: HeadlessResult::NotApproved(
last_error.unwrap_or_else(|| "Aborted".to_string()),
),
iterations: current_iteration,
last_review,
last_fix,
};
}
RallyState::Error => {
return HeadlessOutcome {
result: HeadlessResult::Error(
last_error.unwrap_or_else(|| "Unknown error".to_string()),
),
iterations: current_iteration,
last_review,
last_fix,
};
}
_ => {}
},
RallyEvent::ReviewCompleted(output) => {
eprintln!("{}", format_review_output(&output));
last_review = Some(output);
}
RallyEvent::FixCompleted(output) => {
eprintln!("{}", format_fix_output(&output));
last_fix = Some(output);
}
RallyEvent::Approved(summary) => {
eprintln!("\n[Approved] {}", summary);
return HeadlessOutcome {
result: HeadlessResult::Approved(summary),
iterations: current_iteration,
last_review,
last_fix,
};
}
RallyEvent::Error(msg) => {
eprintln!("\n[Error] {}", msg);
last_error = Some(msg);
}
RallyEvent::Log(msg) => {
eprintln!(" {}", msg);
}
RallyEvent::AgentToolUse(name, _input) => {
eprintln!(" > {}", name);
}
RallyEvent::AgentToolResult(name, result) => {
let truncated = truncate_str(&result, 200);
eprintln!(" < {}: {}", name, truncated);
}
RallyEvent::AgentThinking(_)
| RallyEvent::AgentText(_)
| RallyEvent::Paused
| RallyEvent::Resumed => {}
RallyEvent::ClarificationNeeded(question) => {
eprintln!(" [Clarification needed] {}", question);
eprintln!(" -> Auto-skipping (headless mode)");
let _ = cmd_tx.send(OrchestratorCommand::SkipClarification).await;
}
RallyEvent::PermissionNeeded(action, reason) => {
eprintln!(" [Permission needed] {}: {}", action, reason);
eprintln!(" -> Auto-denying (headless mode, no human to confirm)");
let _ = cmd_tx
.send(OrchestratorCommand::PermissionResponse(false))
.await;
}
RallyEvent::ReviewPostConfirmNeeded(info) => {
eprintln!(
" [Post review] {}: {} ({} comments)",
info.action, info.summary, info.comment_count
);
if local_mode {
eprintln!(" -> Skipping (local mode, no PR to post to)");
let _ = cmd_tx
.send(OrchestratorCommand::PostConfirmResponse(false))
.await;
} else {
eprintln!(" -> Auto-approving post (headless mode)");
let _ = cmd_tx
.send(OrchestratorCommand::PostConfirmResponse(true))
.await;
}
}
RallyEvent::FixPostConfirmNeeded(info) => {
eprintln!(
" [Post fix] {} (files: {})",
info.summary,
info.files_modified.join(", ")
);
if local_mode {
eprintln!(" -> Skipping (local mode, no PR to post to)");
let _ = cmd_tx
.send(OrchestratorCommand::PostConfirmResponse(false))
.await;
} else {
eprintln!(" -> Auto-approving post (headless mode)");
let _ = cmd_tx
.send(OrchestratorCommand::PostConfirmResponse(true))
.await;
}
}
}
}
HeadlessOutcome {
result: HeadlessResult::NotApproved(
last_error.unwrap_or_else(|| "Rally ended unexpectedly".to_string()),
),
iterations: current_iteration,
last_review,
last_fix,
}
}
pub fn format_review_output(output: &ReviewerOutput) -> String {
let mut lines = Vec::new();
let action_str = match output.action {
ReviewAction::Approve => "approve",
ReviewAction::RequestChanges => "request_changes",
ReviewAction::Comment => "comment",
};
lines.push(format!("[Review] Action: {}", action_str));
lines.push(format!(" Summary: {}", output.summary));
if !output.comments.is_empty() {
lines.push(format!(" Comments ({}):", output.comments.len()));
for comment in &output.comments {
let severity = match comment.severity {
CommentSeverity::Critical => "critical",
CommentSeverity::Major => "major",
CommentSeverity::Minor => "minor",
CommentSeverity::Suggestion => "suggestion",
};
let location = format!("{}:{}", comment.path, comment.line);
lines.push(format!(
" - {} [{}] {}",
location, severity, comment.body
));
}
}
if !output.blocking_issues.is_empty() {
lines.push(" Blocking issues:".to_string());
for issue in &output.blocking_issues {
lines.push(format!(" - {}", issue));
}
}
lines.join("\n")
}
pub fn format_fix_output(output: &RevieweeOutput) -> String {
let mut lines = Vec::new();
let status_str = match output.status {
RevieweeStatus::Completed => "completed",
RevieweeStatus::NeedsClarification => "needs_clarification",
RevieweeStatus::NeedsPermission => "needs_permission",
RevieweeStatus::Error => "error",
};
lines.push(format!("[Fix] Status: {}", status_str));
lines.push(format!(" Summary: {}", output.summary));
if !output.files_modified.is_empty() {
lines.push(" Files modified:".to_string());
for file in &output.files_modified {
lines.push(format!(" - {}", file));
}
}
if let Some(error) = &output.error_details {
lines.push(format!(" Error: {}", error));
}
lines.join("\n")
}
fn truncate_str(s: &str, max_chars: usize) -> String {
let char_count = s.chars().count();
if char_count <= max_chars {
return s.to_string();
}
let byte_end = s
.char_indices()
.nth(max_chars)
.map(|(i, _)| i)
.unwrap_or(s.len());
format!("{}...", &s[..byte_end])
}
fn build_json_output(outcome: &HeadlessOutcome) -> HeadlessJsonOutput {
let (result_kind, summary) = match &outcome.result {
HeadlessResult::Approved(summary) => (HeadlessResultKind::Approved, summary.clone()),
HeadlessResult::NotApproved(reason) => (HeadlessResultKind::NotApproved, reason.clone()),
HeadlessResult::Error(msg) => (HeadlessResultKind::Error, msg.clone()),
};
HeadlessJsonOutput {
result: result_kind,
iterations: outcome.iterations,
summary,
last_review: outcome.last_review.clone(),
last_fix: outcome.last_fix.clone(),
}
}
fn write_json_stdout(output: &HeadlessJsonOutput) {
use std::io::Write;
let stdout = std::io::stdout();
let mut handle = stdout.lock();
match serde_json::to_writer(&mut handle, output) {
Ok(()) => {
let _ = writeln!(handle);
let _ = handle.flush();
}
Err(e) => {
eprintln!("[Headless] JSON serialization failed: {}", e);
}
}
}
pub fn write_error_json(error: &str) {
let output = HeadlessJsonOutput {
result: HeadlessResultKind::Error,
iterations: 0,
summary: error.to_string(),
last_review: None,
last_fix: None,
};
write_json_stdout(&output);
}
async fn collect_untracked_diff(dir: &str) -> String {
let ls_output = tokio::process::Command::new("git")
.args(["ls-files", "--others", "--exclude-standard"])
.current_dir(dir)
.output()
.await;
let untracked_files: Vec<String> = match ls_output {
Ok(output) if output.status.success() => String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect(),
_ => return String::new(),
};
if untracked_files.is_empty() {
return String::new();
}
eprintln!(
"[Headless] Including {} untracked file(s) in diff",
untracked_files.len()
);
let mut parts = Vec::new();
for filename in &untracked_files {
let diff_output = tokio::process::Command::new("git")
.args([
"diff",
"--no-ext-diff",
"--no-color",
"--no-index",
"--",
"/dev/null",
filename,
])
.current_dir(dir)
.output()
.await;
if let Ok(output) = diff_output {
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
if !stdout.trim().is_empty() {
parts.push(stdout);
}
}
}
parts.join("\n")
}
fn detect_local_base_branch(working_dir: Option<&str>) -> Option<String> {
let dir = working_dir.unwrap_or(".");
let output = std::process::Command::new("git")
.args(["symbolic-ref", "refs/remotes/origin/HEAD"])
.current_dir(dir)
.output()
.ok()?;
if output.status.success() {
let ref_str = String::from_utf8_lossy(&output.stdout);
ref_str
.trim()
.strip_prefix("refs/remotes/origin/")
.map(|s| s.to_string())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ai::adapter::{CommentSeverity, ReviewAction, ReviewComment, RevieweeStatus};
#[test]
fn test_format_review_output_approve() {
let output = ReviewerOutput {
action: ReviewAction::Approve,
summary: "All looks good".to_string(),
comments: vec![],
blocking_issues: vec![],
};
let text = format_review_output(&output);
assert!(text.contains("[Review] Action: approve"));
assert!(text.contains("Summary: All looks good"));
assert!(!text.contains("{"));
assert!(!text.contains("}"));
}
#[test]
fn test_format_review_output_request_changes() {
let output = ReviewerOutput {
action: ReviewAction::RequestChanges,
summary: "Found 2 issues".to_string(),
comments: vec![
ReviewComment {
path: "src/main.rs".to_string(),
line: 42,
body: "Variable should be constant".to_string(),
severity: CommentSeverity::Major,
},
ReviewComment {
path: "src/lib.rs".to_string(),
line: 10,
body: "Consider renaming".to_string(),
severity: CommentSeverity::Minor,
},
],
blocking_issues: vec!["Error handling missing".to_string()],
};
let text = format_review_output(&output);
assert!(text.contains("[Review] Action: request_changes"));
assert!(text.contains("Comments (2):"));
assert!(text.contains("src/main.rs:42 [major]"));
assert!(text.contains("src/lib.rs:10 [minor]"));
assert!(text.contains("Blocking issues:"));
assert!(text.contains("Error handling missing"));
assert!(!text.contains("\"action\""));
assert!(!text.contains("\"summary\""));
}
#[test]
fn test_format_review_output_suggestion_severity() {
let output = ReviewerOutput {
action: ReviewAction::Comment,
summary: "General feedback".to_string(),
comments: vec![ReviewComment {
path: "README.md".to_string(),
line: 1,
body: "Update docs".to_string(),
severity: CommentSeverity::Suggestion,
}],
blocking_issues: vec![],
};
let text = format_review_output(&output);
assert!(text.contains("README.md:1 [suggestion] Update docs"));
}
#[test]
fn test_format_fix_output_completed() {
let output = RevieweeOutput {
status: RevieweeStatus::Completed,
summary: "Fixed all issues".to_string(),
files_modified: vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
question: None,
permission_request: None,
error_details: None,
};
let text = format_fix_output(&output);
assert!(text.contains("[Fix] Status: completed"));
assert!(text.contains("Summary: Fixed all issues"));
assert!(text.contains("Files modified:"));
assert!(text.contains("- src/main.rs"));
assert!(text.contains("- src/lib.rs"));
assert!(!text.contains("\"status\""));
assert!(!text.contains("\"files_modified\""));
}
#[test]
fn test_format_fix_output_error() {
let output = RevieweeOutput {
status: RevieweeStatus::Error,
summary: "Build failed".to_string(),
files_modified: vec![],
question: None,
permission_request: None,
error_details: Some("cargo build exited with code 1".to_string()),
};
let text = format_fix_output(&output);
assert!(text.contains("[Fix] Status: error"));
assert!(text.contains("Error: cargo build exited with code 1"));
}
#[test]
fn test_truncate_str_short() {
assert_eq!(truncate_str("hello", 10), "hello");
}
#[test]
fn test_truncate_str_long() {
let long = "a".repeat(300);
let result = truncate_str(&long, 200);
assert!(result.ends_with("..."));
assert_eq!(result.chars().count(), 203);
}
#[test]
fn test_truncate_str_multibyte() {
let s = "あいうえおかきくけこ"; let result = truncate_str(s, 5);
assert_eq!(result, "あいうえお...");
}
#[test]
fn test_truncate_str_multibyte_mixed() {
let s = "aあbいc";
assert_eq!(truncate_str(s, 3), "aあb...");
assert_eq!(truncate_str(s, 5), "aあbいc");
assert_eq!(truncate_str(s, 6), "aあbいc"); }
#[test]
fn test_truncate_str_emoji() {
let s = "🎉🎊🎈🎁🎂";
let result = truncate_str(s, 3);
assert_eq!(result, "🎉🎊🎈...");
assert_eq!(truncate_str(s, 5), "🎉🎊🎈🎁🎂");
}
#[test]
fn test_truncate_str_exact_boundary() {
let s = "abcde";
assert_eq!(truncate_str(s, 5), "abcde");
assert_eq!(truncate_str(s, 4), "abcd...");
}
#[test]
fn test_truncate_str_empty() {
assert_eq!(truncate_str("", 10), "");
assert_eq!(truncate_str("", 0), "");
}
#[test]
fn test_truncate_str_zero_max() {
assert_eq!(truncate_str("hello", 0), "...");
}
#[test]
fn test_json_output_approved() {
let outcome = HeadlessOutcome {
result: HeadlessResult::Approved("All good, no issues found".to_string()),
iterations: 2,
last_review: Some(ReviewerOutput {
action: ReviewAction::Approve,
summary: "All good".to_string(),
comments: vec![],
blocking_issues: vec![],
}),
last_fix: Some(RevieweeOutput {
status: RevieweeStatus::Completed,
summary: "Fixed".to_string(),
files_modified: vec!["src/main.rs".to_string()],
question: None,
permission_request: None,
error_details: None,
}),
};
insta::assert_json_snapshot!(build_json_output(&outcome), @r#"
{
"result": "approved",
"iterations": 2,
"summary": "All good, no issues found",
"last_review": {
"action": "approve",
"summary": "All good",
"comments": [],
"blocking_issues": []
},
"last_fix": {
"status": "completed",
"summary": "Fixed",
"files_modified": [
"src/main.rs"
]
}
}
"#);
}
#[test]
fn test_json_output_not_approved() {
let outcome = HeadlessOutcome {
result: HeadlessResult::NotApproved("Max iterations reached".to_string()),
iterations: 3,
last_review: Some(ReviewerOutput {
action: ReviewAction::RequestChanges,
summary: "Still has issues".to_string(),
comments: vec![ReviewComment {
path: "src/lib.rs".to_string(),
line: 10,
body: "Fix this".to_string(),
severity: CommentSeverity::Major,
}],
blocking_issues: vec!["Error handling".to_string()],
}),
last_fix: Some(RevieweeOutput {
status: RevieweeStatus::Completed,
summary: "Attempted fix".to_string(),
files_modified: vec!["src/lib.rs".to_string()],
question: None,
permission_request: None,
error_details: None,
}),
};
insta::assert_json_snapshot!(build_json_output(&outcome), @r#"
{
"result": "not_approved",
"iterations": 3,
"summary": "Max iterations reached",
"last_review": {
"action": "request_changes",
"summary": "Still has issues",
"comments": [
{
"path": "src/lib.rs",
"line": 10,
"body": "Fix this",
"severity": "major"
}
],
"blocking_issues": [
"Error handling"
]
},
"last_fix": {
"status": "completed",
"summary": "Attempted fix",
"files_modified": [
"src/lib.rs"
]
}
}
"#);
}
#[test]
fn test_json_output_error_no_review() {
let outcome = HeadlessOutcome {
result: HeadlessResult::Error("Agent crashed".to_string()),
iterations: 0,
last_review: None,
last_fix: None,
};
let output = build_json_output(&outcome);
let json = serde_json::to_value(&output).unwrap();
assert!(!json.as_object().unwrap().contains_key("last_review"));
assert!(!json.as_object().unwrap().contains_key("last_fix"));
insta::assert_json_snapshot!(output, @r#"
{
"result": "error",
"iterations": 0,
"summary": "Agent crashed"
}
"#);
}
#[test]
fn test_collect_sensitive_overrides_empty_when_no_local_overrides() {
let config = Config::default();
let overrides = collect_sensitive_overrides(&config);
assert!(overrides.is_empty());
}
#[test]
fn test_collect_sensitive_overrides_detects_ai_config_keys() {
let mut config = Config::default();
config
.local_overrides
.insert("ai.reviewer".to_string());
config
.local_overrides
.insert("ai.reviewee_additional_tools".to_string());
let overrides = collect_sensitive_overrides(&config);
assert_eq!(overrides.len(), 2);
assert!(overrides.iter().any(|o| o.as_ref() == "ai.reviewer"));
assert!(overrides
.iter()
.any(|o| o.as_ref() == "ai.reviewee_additional_tools"));
}
#[test]
fn test_collect_sensitive_overrides_ignores_non_sensitive_keys() {
let mut config = Config::default();
config
.local_overrides
.insert("diff.theme".to_string());
config
.local_overrides
.insert("keybindings.move_down".to_string());
let overrides = collect_sensitive_overrides(&config);
assert!(overrides.is_empty());
}
#[test]
fn test_collect_sensitive_overrides_detects_local_prompt_files() {
let dir = tempfile::tempdir().unwrap();
let project_root = dir.path().join("project");
let prompts_dir = project_root.join(".octorus/prompts");
std::fs::create_dir_all(&prompts_dir).unwrap();
std::fs::write(prompts_dir.join("reviewer.md"), "custom prompt").unwrap();
let mut config = Config::default();
config.project_root = project_root;
let overrides = collect_sensitive_overrides(&config);
assert_eq!(overrides.len(), 1);
assert!(overrides[0].as_ref().contains("local prompt: reviewer.md"));
}
#[test]
fn test_collect_sensitive_overrides_combines_config_and_prompt_overrides() {
let dir = tempfile::tempdir().unwrap();
let project_root = dir.path().join("project");
let prompts_dir = project_root.join(".octorus/prompts");
std::fs::create_dir_all(&prompts_dir).unwrap();
std::fs::write(prompts_dir.join("reviewee.md"), "custom").unwrap();
let mut config = Config::default();
config.project_root = project_root;
config
.local_overrides
.insert("ai.auto_post".to_string());
let overrides = collect_sensitive_overrides(&config);
assert_eq!(overrides.len(), 2);
assert!(overrides.iter().any(|o| o.as_ref() == "ai.auto_post"));
assert!(overrides
.iter()
.any(|o| o.as_ref().contains("local prompt: reviewee.md")));
}
}