a73x

src/lib.rs

Ref:   Size: 28.3 KiB

pub mod cache;
pub mod cli;
pub mod commit_link;
pub mod dag;
pub mod editor;
pub mod error;
pub mod event;
pub mod identity;
pub mod issue;
pub mod log;
pub mod patch;
pub mod state;
pub mod signing;
pub mod status;
pub mod sync;
pub mod sync_lock;
pub mod trust;
pub mod tui;

use base64::Engine;
use cli::{Commands, IdentityCmd, IssueCmd, KeyCmd, PatchCmd};
use event::ReviewVerdict;
use git2::Repository;


/// Check if the reviewer's base ref has moved ahead of the patch's latest revision.
pub fn staleness_warning(repo: &Repository, patch: &state::PatchState) -> Option<String> {
    let latest_commit = &patch.revisions.last()?.commit;
    let commit_oid = git2::Oid::from_str(latest_commit).ok()?;
    let base_ref = format!("refs/heads/{}", patch.base_ref);
    let base_tip = repo.refname_to_id(&base_ref).ok()?;
    let merge_base = repo.merge_base(commit_oid, base_tip).ok()?;
    if merge_base == base_tip {
        return None;
    }
    let (ahead, _) = repo.graph_ahead_behind(base_tip, merge_base).ok()?;
    if ahead == 0 {
        return None;
    }
    let mb_short = &merge_base.to_string()[..8];
    Some(format!(
        "\u{26a0} Based on {}@{} ({} commits behind your {})",
        patch.base_ref, mb_short, ahead, patch.base_ref
    ))
}

fn maybe_auto_sync(repo: &Repository) {
    let enabled = repo
        .config()
        .ok()
        .and_then(|c| c.get_bool("collab.autoSync").ok())
        .unwrap_or(true);

    if !enabled {
        return;
    }

    let remote = repo
        .config()
        .ok()
        .and_then(|c| c.get_string("collab.autoSyncRemote").ok())
        .unwrap_or_else(|| "origin".to_string());

    eprintln!("Auto-syncing with '{}'...", remote);
    match sync::sync(repo, &remote) {
        Ok(()) => {}
        Err(error::Error::PartialSync { succeeded, total }) => {
            eprintln!("warning: auto-sync partially failed ({}/{} refs pushed)", succeeded, total);
        }
        Err(e) => {
            eprintln!("warning: auto-sync failed: {}", e);
        }
    }
}

pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
    let is_write = cli.command.is_write();
    match cli.command {
        Commands::Init => sync::init(repo),
        Commands::Issue(cmd) => match cmd {
            IssueCmd::Open { title, body, relates_to } => {
                let id = issue::open(repo, &title, &body, relates_to.as_deref())?;
                println!("Opened issue {:.8}", id);
                Ok(())
            }
            IssueCmd::List { all, archived, limit, offset, json, sort } => {
                if json {
                    let output = issue::list_json(repo, all, archived, sort)?;
                    println!("{}", output);
                    return Ok(());
                }
                let entries = issue::list(repo, all, archived, limit, offset, sort)?;
                if entries.is_empty() {
                    println!("No issues found.");
                } else {
                    for e in &entries {
                        let i = &e.issue;
                        let status = i.status.as_str();
                        let labels = if i.labels.is_empty() {
                            String::new()
                        } else {
                            format!(" [{}]", i.labels.join(", "))
                        };
                        let unread = match e.unread {
                            Some(n) if n > 0 => format!(" ({} new)", n),
                            _ => String::new(),
                        };
                        println!(
                            "{:.8}  {:6}  {}{}  (by {}){}",
                            i.id, status, i.title, labels, i.author.name, unread
                        );
                    }
                }
                Ok(())
            }
            IssueCmd::Show { id, json } => {
                if json {
                    let output = issue::show_json(repo, &id)?;
                    println!("{}", output);
                    return Ok(());
                }
                let i = issue::show(repo, &id)?;
                println!("Issue {} [{}]", &i.id[..8], i.status);
                println!("Title: {}", i.title);
                println!("Author: {} <{}>", i.author.name, i.author.email);
                println!("Created: {}", i.created_at);
                if !i.labels.is_empty() {
                    println!("Labels: {}", i.labels.join(", "));
                }
                if !i.assignees.is_empty() {
                    println!("Assignees: {}", i.assignees.join(", "));
                }
                if let Some(ref relates_to) = i.relates_to {
                    println!("Relates-to: {:.8}", relates_to);
                }
                if let Some(ref reason) = i.close_reason {
                    println!("Closed:  {}", reason);
                }
                if !i.body.is_empty() {
                    println!("\n{}", i.body);
                }
                if !i.comments.is_empty() {
                    println!("\n--- Comments ---");
                    for c in &i.comments {
                        println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body);
                    }
                }
                if !i.linked_commits.is_empty() {
                    println!("\n--- Linked Commits ---");
                    for lc in &i.linked_commits {
                        let short_sha = if lc.commit.len() >= 7 { &lc.commit[..7] } else { &lc.commit };
                        let (subject, commit_author) = match git2::Oid::from_str(&lc.commit)
                            .ok()
                            .and_then(|oid| repo.find_commit(oid).ok())
                        {
                            Some(commit) => {
                                let subject = commit
                                    .summary()
                                    .map(|s| truncate_summary(s, 60))
                                    .unwrap_or_default();
                                let author = commit
                                    .author()
                                    .name()
                                    .unwrap_or("unknown")
                                    .to_string();
                                (Some(subject), Some(author))
                            }
                            None => (None, None),
                        };
                        match (subject, commit_author) {
                            (Some(subject), Some(author)) => {
                                println!(
                                    "· linked {} \"{}\" by {} (linked by {}, {})",
                                    short_sha,
                                    subject,
                                    author,
                                    lc.event_author.name,
                                    lc.event_timestamp,
                                );
                            }
                            _ => {
                                println!(
                                    "· linked {} (commit {} not in local repo) (linked by {}, {})",
                                    short_sha,
                                    short_sha,
                                    lc.event_author.name,
                                    lc.event_timestamp,
                                );
                            }
                        }
                    }
                }
                Ok(())
            }
            IssueCmd::Label { id, label } => {
                issue::label(repo, &id, &label)?;
                println!("Label '{}' added.", label);
                Ok(())
            }
            IssueCmd::Unlabel { id, label } => {
                issue::unlabel(repo, &id, &label)?;
                println!("Label '{}' removed.", label);
                Ok(())
            }
            IssueCmd::Assign { id, name } => {
                issue::assign(repo, &id, &name)?;
                println!("Assigned to '{}'.", name);
                Ok(())
            }
            IssueCmd::Unassign { id, name } => {
                issue::unassign(repo, &id, &name)?;
                println!("Unassigned '{}'.", name);
                Ok(())
            }
            IssueCmd::Edit { id, title, body } => {
                issue::edit(repo, &id, title.as_deref(), body.as_deref())?;
                println!("Issue updated.");
                Ok(())
            }
            IssueCmd::Comment { id, body } => {
                issue::comment(repo, &id, &body)?;
                println!("Comment added.");
                Ok(())
            }
            IssueCmd::Close { id, reason } => {
                issue::close(repo, &id, reason.as_deref())?;
                println!("Issue closed.");
                Ok(())
            }
            IssueCmd::Delete { id } => {
                let full_id = issue::delete(repo, &id)?;
                println!("Deleted issue {:.8}", full_id);
                Ok(())
            }
        },
        Commands::Patch(cmd) => match cmd {
            PatchCmd::Create {
                title,
                body,
                base,
                branch,
                fixes,
            } => {
                // Resolve branch name: --branch takes priority, then current branch
                let branch_name = if let Some(b) = branch {
                    b
                } else {
                    // Default to current branch
                    let head_ref = repo.head().map_err(|_| {
                        error::Error::Cmd("cannot determine current branch (detached HEAD?)".to_string())
                    })?;
                    if head_ref.is_branch() {
                        let name = head_ref.shorthand().ok_or_else(|| {
                            error::Error::Cmd("cannot determine branch name".to_string())
                        })?;
                        if name == base {
                            return Err(error::Error::Cmd(
                                "cannot create patch from base branch; switch to a feature branch first".to_string(),
                            ));
                        }
                        name.to_string()
                    } else {
                        // Detached HEAD — auto-create branch
                        let oid = head_ref.target().ok_or_else(|| {
                            error::Error::Cmd("cannot determine HEAD OID".to_string())
                        })?;
                        let commit = repo.find_commit(oid)?;
                        let short_oid = &oid.to_string()[..8];
                        let auto_branch = format!("collab/patch/{}", short_oid);
                        repo.branch(&auto_branch, &commit, false)?;
                        auto_branch
                    }
                };
                let id = patch::create(repo, &title, &body, &base, &branch_name, fixes.as_deref())?;
                println!("Created patch {:.8}", id);
                Ok(())
            }
            PatchCmd::List { all, archived, limit, offset, json, sort } => {
                if json {
                    let output = patch::list_json(repo, all, archived, sort)?;
                    println!("{}", output);
                    return Ok(());
                }
                let entries = patch::list(repo, all, archived, limit, offset, sort)?;
                if entries.is_empty() {
                    println!("No patches found.");
                } else {
                    for e in &entries {
                        let p = &e.patch;
                        let stale = match p.staleness(repo) {
                            Ok((_, behind)) if behind > 0 => format!(" [behind {}]", behind),
                            Ok(_) => String::new(),
                            Err(_) => String::new(),
                        };
                        let unread = match e.unread {
                            Some(n) if n > 0 => format!(" ({} new)", n),
                            _ => String::new(),
                        };
                        println!(
                            "{:.8}  {:6}  {}  (by {}){}{}",
                            p.id, p.status, p.title, p.author.name, stale, unread
                        );
                    }
                }
                Ok(())
            }
            PatchCmd::Show { id, json, revision } => {
                if json {
                    let output = patch::show_json(repo, &id)?;
                    println!("{}", output);
                    return Ok(());
                }
                let p = patch::show(repo, &id)?;
                let rev_count = p.revisions.len();
                let status_detail = match p.staleness(repo) {
                    Ok((_, behind)) if behind > 0 => {
                        format!(" (branch is {} commits behind {})", behind, p.base_ref)
                    }
                    _ => String::new(),
                };
                println!("Patch {} [{}{}] (r{})", &p.id[..8], p.status, status_detail, rev_count);
                println!("Title: {}", p.title);
                println!("Author: {} <{}>", p.author.name, p.author.email);
                match p.resolve_head(repo) {
                    Ok(_) => {
                        println!("Branch: {} -> {}", p.branch, p.base_ref);
                        if let Ok((ahead, behind)) = p.staleness(repo) {
                            let freshness = if behind == 0 { "up-to-date" } else { "outdated" };
                            println!("Commits: {} ahead, {} behind ({})", ahead, behind, freshness);
                        }
                    }
                    Err(_) => {
                        println!("Branch: {} (not found)", p.branch);
                    }
                }
                println!("Created: {}", p.created_at);
                if let Some(ref fixes) = p.fixes {
                    println!("Fixes: {:.8}", fixes);
                }
                // Staleness warning
                if let Some(warning) = staleness_warning(repo, &p) {
                    eprintln!("{}", warning);
                }
                // Show revisions
                if !p.revisions.is_empty() {
                    println!("\n--- Revisions ---");
                    for rev in &p.revisions {
                        let short = if rev.commit.len() >= 8 { &rev.commit[..8] } else { &rev.commit };
                        let body_display = rev.body.as_deref().map(|b| format!("  {}", b)).unwrap_or_default();
                        println!("  r{}: {} ({}){}", rev.number, short, rev.timestamp, body_display);
                    }
                }
                if !p.body.is_empty() {
                    println!("\n{}", p.body);
                }
                // Filter reviews by revision if requested
                let reviews: Vec<_> = if let Some(rev) = revision {
                    p.reviews.iter().filter(|r| r.revision == Some(rev)).collect()
                } else {
                    p.reviews.iter().collect()
                };
                if !reviews.is_empty() {
                    println!("\n--- Reviews ---");
                    for r in &reviews {
                        let rev_label = r.revision.map(|n| format!(" (r{})", n)).unwrap_or_default();
                        println!(
                            "\n{} ({}) - {}{}:\n{}",
                            r.author.name, r.verdict, r.timestamp, rev_label, r.body
                        );
                    }
                }
                // Filter inline comments by revision if requested
                let inline_comments: Vec<_> = if let Some(rev) = revision {
                    p.inline_comments.iter().filter(|c| c.revision == Some(rev)).collect()
                } else {
                    p.inline_comments.iter().collect()
                };
                if !inline_comments.is_empty() {
                    println!("\n--- Inline Comments ---");
                    for c in &inline_comments {
                        let rev_label = c.revision.map(|n| format!(" r{}", n)).unwrap_or_default();
                        println!(
                            "\n{} on {}:{} ({}{}):\n  {}",
                            c.author.name, c.file, c.line, c.timestamp, rev_label, c.body
                        );
                    }
                }
                // Thread comments always shown
                if !p.comments.is_empty() {
                    println!("\n--- Comments ---");
                    for c in &p.comments {
                        println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body);
                    }
                }
                Ok(())
            }
            PatchCmd::Diff { id, revision, between } => {
                if revision.is_some() && between.is_some() {
                    return Err(error::Error::Cmd(
                        "--revision and --between are mutually exclusive".to_string(),
                    ));
                }
                let between_pair = between.map(|v| {
                    let from = v[0];
                    let to = v.get(1).copied();
                    (from, to)
                });
                let diff = patch::diff(repo, &id, revision, between_pair)?;
                if diff.is_empty() {
                    println!("No diff available (commits may be identical).");
                } else {
                    print!("{}", diff);
                }
                Ok(())
            }
            PatchCmd::Comment {
                id,
                body,
                file,
                line,
                revision,
            } => {
                patch::comment(repo, &id, &body, file.as_deref(), line, revision)?;
                println!("Comment added.");
                Ok(())
            }
            PatchCmd::Review { id, verdict, body, revision } => {
                let v: ReviewVerdict = verdict.parse().map_err(|_| {
                    git2::Error::from_str(
                        "verdict must be: approve, request-changes, comment, or reject",
                    )
                })?;
                patch::review(repo, &id, v, &body, revision)?;
                println!("Review submitted.");
                Ok(())
            }
            PatchCmd::Revise { id, body } => {
                patch::revise(repo, &id, body.as_deref())?;
                println!("Patch revised.");
                Ok(())
            }
            PatchCmd::Log { id, json } => {
                let p = patch::patch_log(repo, &id)?;
                if json {
                    let output = patch::patch_log_json(&p)?;
                    println!("{}", output);
                } else {
                    patch::patch_log_to_writer(repo, &p, &mut std::io::stdout())?;
                }
                Ok(())
            }
            PatchCmd::Close { id, reason } => {
                patch::close(repo, &id, reason.as_deref())?;
                println!("Patch closed.");
                Ok(())
            }
            PatchCmd::Delete { id } => {
                let full_id = patch::delete(repo, &id)?;
                println!("Deleted patch {:.8}", full_id);
                Ok(())
            }
            PatchCmd::Checkout { id } => {
                patch::checkout(repo, &id)?;
                Ok(())
            }
        },
        Commands::Status => {
            let project_status = status::compute(repo)?;
            print!("{}", project_status);
            Ok(())
        }
        Commands::Log { limit } => log::print_log(repo, limit),
        Commands::Dashboard => tui::run(repo),
        Commands::Completions { .. } => unreachable!("handled before repo open"),
        Commands::Sync { remote } => sync::sync(repo, &remote),
        Commands::InitKey { force } => {
            let config_dir = signing::signing_key_dir()?;
            let sk_path = config_dir.join("signing-key");
            if sk_path.exists() && !force {
                return Err(error::Error::Signing(
                    "signing key already exists; use --force to overwrite".to_string(),
                ));
            }

            let vk = signing::generate_keypair(&config_dir)?;
            let pubkey_b64 =
                base64::engine::general_purpose::STANDARD.encode(vk.to_bytes());
            println!("Signing key generated.");
            println!("Public key: {}", pubkey_b64);
            Ok(())
        }
        Commands::Whoami => {
            let info = identity::whoami(repo)?;
            println!("{}", info);
            Ok(())
        }
        Commands::Identity(cmd) => match cmd {
            IdentityCmd::Alias { email } => {
                let added = identity::add_alias(repo, &email)?;
                if added {
                    println!("Alias '{}' added.", email);
                } else {
                    println!("Alias '{}' already exists.", email);
                }
                Ok(())
            }
            IdentityCmd::Unalias { email } => {
                identity::remove_alias(repo, &email)?;
                println!("Alias '{}' removed.", email);
                Ok(())
            }
            IdentityCmd::List => {
                let author = identity::get_author(repo)?;
                println!("Name: {}", author.name);
                println!("Email: {} (primary)", author.email);
                let aliases = identity::load_aliases(repo)?;
                if aliases.is_empty() {
                    println!("Aliases: (none)");
                } else {
                    println!("Aliases:");
                    for alias in &aliases {
                        println!("  {}", alias);
                    }
                }
                Ok(())
            }
        },
        Commands::Key(cmd) => match cmd {
            KeyCmd::Add {
                pubkey,
                self_key,
                label,
                global,
            } => {
                if self_key && pubkey.is_some() {
                    return Err(error::Error::Cmd(
                        "cannot specify both --self and a public key argument".to_string(),
                    ));
                }
                let key = if self_key {
                    let config_dir = signing::signing_key_dir()?;
                    let vk = signing::load_verifying_key(&config_dir)?;
                    base64::engine::general_purpose::STANDARD.encode(vk.to_bytes())
                } else {
                    match pubkey {
                        Some(k) => k,
                        None => {
                            return Err(error::Error::Cmd(
                                "public key argument required (or use --self)".to_string(),
                            ));
                        }
                    }
                };
                trust::validate_pubkey(&key)?;
                let added = if global {
                    trust::save_trusted_key_global(&key, label.as_deref())?
                } else {
                    trust::save_trusted_key(repo, &key, label.as_deref())?
                };
                if added {
                    let label_display = label
                        .as_ref()
                        .map(|l| format!(" ({})", l))
                        .unwrap_or_default();
                    let scope = if global { " (global)" } else { "" };
                    println!("Trusted key added{}: {}{}", scope, key, label_display);
                } else {
                    println!("Key {} is already trusted.", key);
                }
                Ok(())
            }
            KeyCmd::List { global } => {
                if global {
                    let path = trust::global_trusted_keys_path()?;
                    let keys = trust::parse_trusted_keys_file(&path)?;
                    if keys.is_empty() {
                        println!("No global trusted keys configured.");
                    } else {
                        for k in &keys {
                            match &k.label {
                                Some(l) => println!("{}  {}", k.pubkey, l),
                                None => println!("{}", k.pubkey),
                            }
                        }
                    }
                } else {
                    let policy = trust::load_trust_policy(repo)?;
                    match policy {
                        trust::TrustPolicy::Unconfigured => {
                            println!("No trusted keys configured.");
                        }
                        trust::TrustPolicy::Configured(keys) => {
                            if keys.is_empty() {
                                println!("No trusted keys configured.");
                            } else {
                                for k in &keys {
                                    match &k.label {
                                        Some(l) => println!("{}  {}", k.pubkey, l),
                                        None => println!("{}", k.pubkey),
                                    }
                                }
                            }
                        }
                    }
                }
                Ok(())
            }
            KeyCmd::Remove { pubkey, global } => {
                let removed = if global {
                    trust::remove_trusted_key_global(&pubkey)?
                } else {
                    trust::remove_trusted_key(repo, &pubkey)?
                };
                let label_display = removed
                    .label
                    .as_ref()
                    .map(|l| format!(" ({})", l))
                    .unwrap_or_default();
                let scope = if global { " (global)" } else { "" };
                println!("Removed trusted key{}: {}{}", scope, removed.pubkey, label_display);
                Ok(())
            }
        },
        Commands::Search { query } => search(repo, &query),
    }?;

    if is_write {
        maybe_auto_sync(repo);
    }

    Ok(())
}

fn search(repo: &Repository, query: &str) -> Result<(), error::Error> {
    let q = query.to_lowercase();

    let issues = state::list_issues_with_archived(repo)?;
    let mut issue_results: Vec<(&str, &str, &str, String)> = Vec::new();

    for issue in &issues {
        let mut matches = Vec::new();
        if issue.title.to_lowercase().contains(&q) {
            matches.push("title");
        }
        if issue.body.to_lowercase().contains(&q) {
            matches.push("body");
        }
        if issue.comments.iter().any(|c| c.body.to_lowercase().contains(&q)) {
            matches.push("comment");
        }
        if !matches.is_empty() {
            issue_results.push((
                &issue.id,
                issue.status.as_str(),
                &issue.title,
                matches.join(", "),
            ));
        }
    }

    let patches = state::list_patches_with_archived(repo)?;
    let mut patch_results: Vec<(&str, &str, &str, String)> = Vec::new();

    for patch in &patches {
        let mut matches = Vec::new();
        if patch.title.to_lowercase().contains(&q) {
            matches.push("title");
        }
        if patch.body.to_lowercase().contains(&q) {
            matches.push("body");
        }
        if patch.comments.iter().any(|c| c.body.to_lowercase().contains(&q)) {
            matches.push("comment");
        }
        if patch.reviews.iter().any(|r| r.body.to_lowercase().contains(&q)) {
            matches.push("review");
        }
        if patch.inline_comments.iter().any(|ic| ic.body.to_lowercase().contains(&q)) {
            matches.push("inline comment");
        }
        if !matches.is_empty() {
            patch_results.push((
                &patch.id,
                patch.status.as_str(),
                &patch.title,
                matches.join(", "),
            ));
        }
    }

    println!("Issues:");
    if issue_results.is_empty() {
        println!("  (none)");
    } else {
        for (id, status, title, match_field) in &issue_results {
            println!("  {:.8}  {:6}  {}  ({} match)", id, status, title, match_field);
        }
    }
    println!();
    println!("Patches:");
    if patch_results.is_empty() {
        println!("  (none)");
    } else {
        for (id, status, title, match_field) in &patch_results {
            println!("  {:.8}  {:6}  {}  ({} match)", id, status, title, match_field);
        }
    }

    Ok(())
}

pub(crate) fn truncate_summary(s: &str, max_chars: usize) -> String {
    let mut out = String::new();
    for (count, c) in s.chars().enumerate() {
        if count + 1 > max_chars {
            out.push('…');
            return out;
        }
        out.push(c);
    }
    out
}