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 std::path::{Path, PathBuf};

use crate::cli::LearnCliArgs;
use crate::runtime::CommandContext;
use crate::style::{self, sym};
use crate::support::util::exit_code;

const DEFAULT_MAX_PAIRS: usize = 10;
const MANUAL_NOTE_ASSISTANT_TEXT: &str = "(manual learning note supplied by user)";

pub(crate) async fn handle_learn(ctx: &CommandContext, args: LearnCliArgs) {
    let transcript = args
        .transcript
        .or_else(|| latest_claude_transcript(&ctx.project));
    let transcript_json = transcript
        .as_ref()
        .map(|path| path.to_string_lossy().to_string());
    let session_id = args
        .session
        .or_else(|| transcript.as_deref().and_then(session_id_from_transcript))
        .unwrap_or_else(|| "manual-learn".to_owned());

    let mut pairs = transcript
        .as_deref()
        .map(|path| extract_pairs(&args.client, path))
        .unwrap_or_default();

    let note = args
        .note
        .as_deref()
        .map(str::trim)
        .filter(|s| !s.is_empty());
    if let Some(note) = note {
        pairs.push(crate::session_mine::extract::Pair {
            user_prompt: note.to_owned(),
            assistant_text: MANUAL_NOTE_ASSISTANT_TEXT.to_owned(),
        });
    }

    if pairs.is_empty() {
        if args.json {
            print_json(&serde_json::json!({
                "status": "nothing",
                "sessionId": session_id,
                "transcript": transcript_json,
                "message": "No transcript pairs or note found"
            }));
        } else {
            println!(
                "{} No recent session transcript or note found.",
                style::warn(sym::WARN)
            );
            println!(
                "  {} Pass {} or {}.",
                style::pewter(sym::BULLET),
                style::cmd("difflore learn --note \"...\""),
                style::cmd("difflore learn --transcript <path>")
            );
        }
        return;
    }

    let cwd = ctx.project.to_string_lossy().to_string();
    let result = crate::session_mine::run_targeted_pairs_once(
        &args.client,
        pairs,
        Some(&session_id),
        Some(&cwd),
        crate::session_mine::GateMode::ManualLearn,
    )
    .await;

    match result {
        Ok(()) => {
            if args.json {
                print_json(&serde_json::json!({
                    "status": "ok",
                    "sessionId": session_id,
                    "transcript": transcript_json,
                }));
            } else {
                println!(
                    "{} DiffLore ran learning extraction for session `{}`.",
                    style::ok(sym::OK),
                    session_id
                );
                println!(
                    "  {} Candidates still require normal review/approval.",
                    style::pewter(sym::BULLET)
                );
            }
        }
        Err(e) => {
            if args.json {
                print_json(&serde_json::json!({
                    "status": "error",
                    "sessionId": session_id,
                    "transcript": transcript_json,
                    "error": e
                }));
            } else {
                eprintln!("{} learning extraction failed: {e}", style::err(sym::ERR));
            }
            exit_code(1);
        }
    }
}

fn extract_pairs(client: &str, transcript_path: &Path) -> Vec<crate::session_mine::extract::Pair> {
    let transcript = transcript_path.to_string_lossy();
    crate::session_mine::extract::extract_recent_session_pairs(
        crate::session_mine::extract::ExtractArgs {
            platform: crate::session_mine::extract::Platform::from_client_name(client),
            transcript_path: Some(&transcript),
            session_id: None,
            max_pairs: DEFAULT_MAX_PAIRS,
        },
    )
    .unwrap_or_default()
}

fn latest_claude_transcript(repo_root: &Path) -> Option<PathBuf> {
    let home = claude_home_dir()?;
    latest_claude_transcript_under_home(&home, repo_root)
}

fn latest_claude_transcript_under_home(home: &Path, repo_root: &Path) -> Option<PathBuf> {
    let slug = claude_project_slug(repo_root)?;
    let root = home.join(".claude").join("projects").join(slug);
    newest_jsonl_under(&root)
}

fn claude_home_dir() -> Option<PathBuf> {
    if let Some(home) =
        difflore_core::infra::env::var_os(difflore_core::infra::env::DIFFLORE_CLAUDE_HOME)
        && !home.is_empty()
    {
        return Some(PathBuf::from(home));
    }
    dirs::home_dir()
}

fn claude_project_slug(repo_root: &Path) -> Option<String> {
    let canonical = repo_root.canonicalize().ok()?;
    Some(claude_project_slug_from_path_text(
        &canonical.to_string_lossy(),
    ))
}

fn claude_project_slug_from_path_text(path: &str) -> String {
    let path = path.strip_prefix(r"\\?\").unwrap_or(path);
    path.chars()
        .map(|ch| match ch {
            '\\' | '/' | ':' | '<' | '>' | '"' | '|' | '?' | '*' => '-',
            _ => ch,
        })
        .collect()
}

fn newest_jsonl_under(root: &Path) -> Option<PathBuf> {
    let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
    visit_jsonl(root, &mut newest);
    newest.map(|(_, path)| path)
}

fn visit_jsonl(dir: &Path, newest: &mut Option<(std::time::SystemTime, PathBuf)>) {
    let Ok(entries) = std::fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            visit_jsonl(&path, newest);
            continue;
        }
        if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
            continue;
        }
        let Ok(meta) = entry.metadata() else {
            continue;
        };
        let Ok(modified) = meta.modified() else {
            continue;
        };
        let replace = newest
            .as_ref()
            .is_none_or(|(current, _)| modified > *current);
        if replace {
            *newest = Some((modified, path));
        }
    }
}

fn session_id_from_transcript(path: &Path) -> Option<String> {
    path.file_stem()
        .and_then(|stem| stem.to_str())
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(str::to_owned)
}

fn print_json(value: &serde_json::Value) {
    println!(
        "{}",
        serde_json::to_string_pretty(value).unwrap_or_else(|_| "{}".to_owned())
    );
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn session_id_uses_file_stem() {
        assert_eq!(
            session_id_from_transcript(Path::new("/tmp/sess-1.jsonl")).as_deref(),
            Some("sess-1")
        );
    }

    #[test]
    fn newest_jsonl_under_picks_latest_modified_file() {
        let dir = tempfile::TempDir::new().expect("tempdir");
        let older = dir.path().join("older.jsonl");
        let newer = dir.path().join("nested").join("newer.jsonl");
        std::fs::write(&older, "{}\n").expect("write older");
        std::fs::create_dir_all(newer.parent().expect("parent")).expect("mkdir");
        std::thread::sleep(std::time::Duration::from_millis(5));
        std::fs::write(&newer, "{}\n").expect("write newer");

        assert_eq!(
            newest_jsonl_under(dir.path()).as_deref(),
            Some(newer.as_path())
        );
    }

    #[test]
    fn latest_claude_transcript_is_scoped_to_current_project_slug() {
        let home = tempfile::TempDir::new().expect("home tempdir");
        let repo = tempfile::TempDir::new().expect("repo tempdir");
        let repo_slug = claude_project_slug(repo.path()).expect("repo slug");
        let project_dir = home.path().join(".claude").join("projects").join(repo_slug);
        let other_dir = home
            .path()
            .join(".claude")
            .join("projects")
            .join("-other-repo");
        std::fs::create_dir_all(&project_dir).expect("project dir");
        std::fs::create_dir_all(&other_dir).expect("other dir");
        let expected = project_dir.join("current.jsonl");
        let other = other_dir.join("newer.jsonl");
        std::fs::write(&expected, "{}\n").expect("write current transcript");
        std::thread::sleep(std::time::Duration::from_millis(5));
        std::fs::write(&other, "{}\n").expect("write other transcript");

        assert_eq!(
            latest_claude_transcript_under_home(home.path(), repo.path()).as_deref(),
            Some(expected.as_path()),
            "default learn transcript must not cross into another Claude project"
        );
    }
}