difflore-cli 0.2.0

Your AI coding agent learned public code, not your team's private decisions. difflore turns past PR reviews into source-backed local rules.
use crate::style;
use crate::support::util::exit_code;

pub(crate) mod audit_history;
pub(crate) mod drain;
mod embedding_degradation;
pub(crate) mod fix;
pub(crate) mod labels;
pub(crate) mod memory_snapshot;
pub(crate) mod probes;
pub(crate) mod report;
pub(crate) mod table;
mod util;

/// Flags the doctor command exposes, bundled so `handle_doctor` keeps a single
/// signature as the surface grows.
#[derive(Debug)]
pub(crate) struct DoctorArgs {
    pub report: Option<String>,
    pub fix: bool,
    pub drain_abandoned: bool,
    pub older_than: String,
    pub no_dry_run: bool,
    pub json: bool,
}

pub(crate) async fn handle_doctor(ctx: &crate::runtime::CommandContext, args: DoctorArgs) {
    let DoctorArgs {
        report,
        fix: fix_mode,
        drain_abandoned,
        older_than,
        no_dry_run,
        json,
    } = args;

    if drain_abandoned {
        // Dry-run by default; `--no-dry-run` is required to actually write.
        let cutoff = match drain::parse_older_than(&older_than) {
            Ok(d) => d,
            Err(msg) => {
                style::report_error(&msg, "", &[]);
                exit_code(2);
            }
        };
        let dry_run = !no_dry_run;
        match drain::run_drain(ctx, cutoff, dry_run).await {
            Ok(outcome) => drain::render_outcome(&outcome, json),
            Err(msg) => {
                style::report_error(&msg, "", &[]);
                exit_code(1);
            }
        }
        return;
    }

    if let Some(report_target) = report {
        let md = report::build_doctor_report(ctx).await;
        write_report(&report_target, &md);
    } else {
        let rendered = table::render_table(ctx).await;
        print!("{rendered}");
        if let Some(warning) = slow_drain_warning(ctx).await {
            println!();
            println!("  {warning}");
        }
        if fix_mode {
            fix::run_fix_pass();
        } else if fix::has_fixable() {
            // Nudge only when there's something to fix, so a healthy install
            // isn't nagged.
            println!();
            println!(
                "  {} {} {} {}",
                style::emerald(style::sym::TIP),
                style::pewter("Run"),
                style::cmd("difflore doctor --fix"),
                style::pewter("to auto-repair these."),
            );
        }
    }
}

fn write_report(target: &str, md: &str) {
    if target == "-" {
        print!("{md}");
        return;
    }

    let path = if target.is_empty() {
        let ts = chrono::Utc::now().format("%Y%m%d-%H%M%S");
        // Park reports under `~/.difflore/reports/` so they don't accumulate
        // in the project root; fall back to cwd if that path is unavailable.
        let dir = match difflore_core::infra::paths::data_home() {
            Ok(d) => d.join("reports"),
            Err(_) => std::path::PathBuf::from("."),
        };
        dir.join(format!("difflore-bug-report-{ts}.md"))
    } else {
        std::path::PathBuf::from(target)
    };

    if let Some(parent) = path.parent()
        && !parent.as_os_str().is_empty()
        && let Err(e) = std::fs::create_dir_all(parent)
    {
        style::report_error(
            &format!("Failed to create reports dir {}: {e}", parent.display()),
            "",
            &[],
        );
        exit_code(1);
    }

    match std::fs::write(&path, md) {
        Ok(()) => println!(
            "{} Bug report written to {}",
            style::emerald(style::sym::OK),
            style::ident(&path.display().to_string())
        ),
        Err(e) => {
            style::report_error(&format!("Failed to write report: {e}"), "", &[]);
            exit_code(1);
        }
    }
}

/// Thresholds for the slow-drain warning; healthy installs stay well below
/// these `pending`-row counts.
const SLOW_DRAIN_CLOUD_THRESHOLD: i64 = 500;
const SLOW_DRAIN_OBSERVATION_THRESHOLD: i64 = 200;

/// Single-line warning when either outbox queue is over its slow-drain
/// threshold; `None` otherwise.
async fn slow_drain_warning(ctx: &crate::runtime::CommandContext) -> Option<String> {
    use difflore_core::cloud::observations::ObservationEmitter;
    use difflore_core::cloud::outbox::OutboxQueue;

    let outbox = OutboxQueue::new(ctx.db.clone());
    let cloud_counts = outbox.pending_counts_by_kind().await.ok()?;
    let cloud_total: i64 = cloud_counts.iter().map(|(_, n)| *n).sum();

    let obs_pending = match ObservationEmitter::open_default().await {
        Ok(e) => e.pending_upload_count().await.unwrap_or(0),
        Err(_) => 0,
    };

    let cloud_hot = cloud_total > SLOW_DRAIN_CLOUD_THRESHOLD;
    let obs_hot = obs_pending > SLOW_DRAIN_OBSERVATION_THRESHOLD;
    if !cloud_hot && !obs_hot {
        return None;
    }

    let mut parts = Vec::new();
    if cloud_hot {
        parts.push(format!("{cloud_total} cloud upload{}", plural(cloud_total)));
    }
    if obs_hot && obs_pending > 0 {
        parts.push(format!("{obs_pending} agent event{}", plural(obs_pending)));
    }

    // The cloud_outbox raw kinds (observation / session_mined_candidate /
    // mcp_query) are opt-in and are NOT drained by a plain `cloud sync` — only
    // the flywheel agent-event queue is. Suggest the right command so the hint
    // isn't a dead end that leaves the queue "stuck" forever.
    let command = if cloud_hot {
        "run `difflore cloud sync --include-observations --include-candidates --include-telemetry` (raw queues are opt-in)"
    } else {
        "run `difflore cloud sync`"
    };

    Some(format!(
        "{} {} {}; {command}; if it stays queued, attach `difflore doctor --report`.",
        style::amber(style::sym::WARN),
        style::pewter("upload queue:"),
        parts.join(" + "),
    ))
}

const fn plural(n: i64) -> &'static str {
    if n == 1 { "" } else { "s" }
}