ah-cli 0.3.0

Agent History Search - cross-agent session full-text search CLI
use std::fs;
use std::process::{self, Command};
use std::time::SystemTime;

use crate::agents;
use crate::agents::common::canonical_home;
use crate::cli::{Field, FilterArgs, ResumeArgs};
use crate::output::strip_quotes;
use crate::remote;
use crate::resolver::{self, shell_quote};
use crate::subcmd;

pub fn run(args: ResumeArgs, filter: &FilterArgs) -> Result<(), String> {
    let home = canonical_home();
    let explicit_session = subcmd::read_session_ref(args.session.as_deref());

    if let Some(session) = explicit_session.as_deref() {
        let unquoted = strip_quotes(session);
        if let Some((remote_def, remote_ref)) = remote::parse_remote_path(unquoted) {
            if args.print {
                println!(
                    "{}",
                    remote::format_remote_resume_command(remote_def, remote_ref, &args.extra_args,)
                );
                return Ok(());
            }
            remote::exec_remote_resume(remote_def, remote_ref, &args);
        }
        remote::check_unknown_remote(unquoted)?;
    }

    // Only require an explicit REMOTE:REF when no session was given. With an
    // explicit local path (e.g. picked from interactive `--remote` mixed list),
    // we resume the local session even if `filter.remote` is set.
    if explicit_session.is_none() && !filter.remote.is_empty() {
        return Err(
            "Remote session resume requires an explicit REMOTE:REF session.\n\
             Example: ah resume mydev:SESSION_ID"
                .into(),
        );
    }

    let full_cmd = if let Some(session) = explicit_session.as_deref() {
        build_resume_command_for_ref(&args, filter, session, &home)?
    } else {
        build_resume_command(&args, filter)?
    };
    if args.print {
        println!("{}", full_cmd);
        return Ok(());
    }
    exec_resume(&full_cmd);
}

pub fn build_resume_command(args: &ResumeArgs, filter: &FilterArgs) -> Result<String, String> {
    let home = canonical_home();
    if let Some(session) = args.session.as_deref() {
        build_resume_command_for_ref(args, filter, session, &home)
    } else {
        build_resume_command_for_lookup(args, filter, &home)
    }
}

fn build_resume_command_for_lookup(
    args: &ResumeArgs,
    filter: &FilterArgs,
    home: &std::path::Path,
) -> Result<String, String> {
    let filters = filter.to_filters();
    let path = subcmd::resolve_resumable_session(
        None,
        filter.query.as_deref(),
        &filters,
        home,
        filter.search_mode(),
        filter.since_time()?,
        filter.until_time()?,
    )?;
    build_resume_command_for_path(args, &path, home)
}

fn build_resume_command_for_ref(
    args: &ResumeArgs,
    _filter: &FilterArgs,
    session: &str,
    home: &std::path::Path,
) -> Result<String, String> {
    let path = subcmd::resolve_session_ref(session, home)?;
    build_resume_command_for_path(args, &path, home)
}

fn build_resume_command_for_path(
    args: &ResumeArgs,
    path: &std::path::Path,
    home: &std::path::Path,
) -> Result<String, String> {
    let plugin = agents::find_plugin_for_path(path);
    let mtime = fs::metadata(path)
        .and_then(|m| m.modified())
        .unwrap_or(SystemTime::UNIX_EPOCH);
    let fields = resolver::resolve_fields(
        path,
        plugin,
        mtime,
        home,
        &[Field::ResumeCmd],
        &Default::default(),
    );

    let cmd = fields
        .get(&Field::ResumeCmd)
        .map(|v| v.as_str())
        .unwrap_or("");
    if cmd.is_empty() {
        return Err("No resume command available for this session.".to_string());
    }

    let full_cmd = if args.extra_args.is_empty() {
        cmd.to_string()
    } else {
        let extra = args
            .extra_args
            .iter()
            .map(|a| shell_quote(a))
            .collect::<Vec<_>>()
            .join(" ");
        format!("{} {}", cmd, extra)
    };

    Ok(full_cmd)
}

pub fn exec_resume(cmd: &str) -> ! {
    exec_command(cmd)
}

#[cfg(unix)]
fn exec_command(cmd: &str) -> ! {
    use std::os::unix::process::CommandExt;
    let err = Command::new("sh").args(["-c", cmd]).exec();
    eprintln!("Failed to exec: {}", err);
    process::exit(1);
}

#[cfg(not(unix))]
fn exec_command(_cmd: &str) -> ! {
    eprintln!("resume is not supported on this platform (requires Unix)");
    process::exit(1);
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::path::Path;
    use tempfile::TempDir;

    fn fixture_path(name: &str) -> std::path::PathBuf {
        Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("tests/fixtures")
            .join(name)
    }

    fn codex_session_copy() -> (TempDir, std::path::PathBuf) {
        let tmp = TempDir::new().unwrap();
        let session_path = tmp
            .path()
            .join(".codex/sessions/2026/03/24/rollout-2026-03-24T20-43-12-codex-sess-001.jsonl");
        fs::create_dir_all(session_path.parent().unwrap()).unwrap();
        fs::copy(fixture_path("codex_session.jsonl"), &session_path).unwrap();
        (tmp, session_path)
    }

    fn default_filter() -> FilterArgs {
        FilterArgs {
            all: false,
            all_remote: false,
            agent: None,
            project: None,
            dir: None,
            query: None,
            prompt_only: false,
            limit: 0,
            running: false,
            remote: vec![],
            since: None,
            until: None,
            color: false,
            no_color: false,
            no_pager: true,
            debug: false,
        }
    }

    #[test]
    fn build_resume_command_for_fixture_path() {
        let home = canonical_home();
        crate::config::init(&home);
        let (_tmp, session_path) = codex_session_copy();

        let args = ResumeArgs {
            common: crate::cli::CommonArgs { fields: None },
            print: true,
            session: Some(session_path.display().to_string()),
            ltsv: false,
            extra_args: Vec::new(),
        };

        let cmd = build_resume_command(&args, &default_filter()).unwrap();
        assert_eq!(
            cmd,
            "cd '/Users/test/api-server' && 'codex' 'resume' 'codex-sess-001'"
        );
    }

    #[test]
    fn build_resume_command_appends_extra_args() {
        let home = canonical_home();
        crate::config::init(&home);
        let (_tmp, session_path) = codex_session_copy();

        let args = ResumeArgs {
            common: crate::cli::CommonArgs { fields: None },
            print: true,
            session: Some(session_path.display().to_string()),
            ltsv: false,
            extra_args: vec!["--model".to_string(), "gpt-5".to_string()],
        };

        let cmd = build_resume_command(&args, &default_filter()).unwrap();
        assert_eq!(
            cmd,
            "cd '/Users/test/api-server' && 'codex' 'resume' 'codex-sess-001' '--model' 'gpt-5'"
        );
    }

    #[test]
    fn remote_flag_without_ref_returns_error() {
        let home = canonical_home();
        crate::config::init(&home);

        let args = ResumeArgs {
            common: crate::cli::CommonArgs { fields: None },
            print: false,
            session: None,
            ltsv: false,
            extra_args: Vec::new(),
        };
        let mut filter = default_filter();
        filter.remote = vec!["mydev".to_string()];

        let result = run(args, &filter);
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .contains("Remote session resume requires an explicit REMOTE:REF")
        );
    }

    #[test]
    fn format_remote_resume_command_basic() {
        let remote = crate::config::RemoteDef {
            name: "mydev".to_string(),
            host: "mydev.example.com".to_string(),
            ah_path: "/usr/local/bin/ah".to_string(),
        };
        let cmd = remote::format_remote_resume_command(&remote, "abc123", &[]);
        assert_eq!(
            cmd,
            "ssh -t -- 'mydev.example.com' '/usr/local/bin/ah' resume 'abc123'"
        );
    }

    #[test]
    fn format_remote_resume_command_with_extra_args() {
        let remote = crate::config::RemoteDef {
            name: "mydev".to_string(),
            host: "mydev.example.com".to_string(),
            ah_path: "ah".to_string(),
        };
        let cmd = remote::format_remote_resume_command(
            &remote,
            "abc123",
            &["--model".to_string(), "opus".to_string()],
        );
        assert_eq!(
            cmd,
            "ssh -t -- 'mydev.example.com' 'ah' resume 'abc123' -- '--model' 'opus'"
        );
    }
}