iqan 0.3.2

Sync Nix flake pins
use anyhow::Result;
use chrono::DateTime;
use std::{
    io::{self, BufRead, Write},
    path::Path,
};

mod flake;
mod lockfile;
mod plan;
mod tarball;

use lockfile::{LockFile, LockNode};
use plan::{Plan, TargetMapping};

fn main() -> Result<()> {
    let mut args = std::env::args_os().skip(1);
    let Some(first) = args.next() else {
        eprintln!("usage:");
        eprintln!("  iqan plan.json");
        eprintln!("    Sync all targets in plan to source.");
        eprintln!("    See plan.json.sample and src/main.rs for guidance.");
        eprintln!("  iqan source-flake");
        eprintln!("    Sync target in cwd to source.");
        eprintln!("  iqan tarball [flake]");
        eprintln!("    Convert nixpkgs channel inputs of the flake (default cwd)");
        eprintln!("    to nixos.org channel tarballs, preserving their revisions.");
        std::process::exit(1);
    };

    if first == *"tarball" {
        let root = match args.next() {
            Some(root) => std::fs::canonicalize(root)?,
            None => std::fs::canonicalize(std::env::current_dir()?)?,
        };
        return tarball::convert(&root);
    }

    let plan_path = std::fs::canonicalize(first)?;
    let plan_stat = std::fs::metadata(&plan_path)?;

    if plan_stat.is_dir() {
        println!("Source: {}", plan_path.to_string_lossy());
        let source = LockFile::from_root(&plan_path)?;
        check(
            &source,
            &std::fs::canonicalize(std::env::current_dir()?)?,
            None,
        )
    } else {
        let plan = Plan::from_path(&plan_path)?;
        println!("Source: {}", plan.source);
        let source = LockFile::from_root(plan.source.as_ref())?;

        for (root, inputs) in plan.targets {
            check(&source, root.as_ref(), Some(inputs))?;
        }
        Ok(())
    }
}

fn check(source: &LockFile, root: &Path, inputs: Option<TargetMapping>) -> Result<()> {
    let root_display = root.to_string_lossy();

    let mut target = LockFile::from_root(root)?;
    println!();
    println!("Target: {root_display}");
    println!("--------{}", "-".repeat(root_display.len()));

    let inputs = inputs.unwrap_or(TargetMapping::Identity(target.get_inputs()));

    for (source_input, target_input) in inputs.iter() {
        let Some(source_node) = source.get_node_via_root(source_input) else {
            println!("[!] source lacked input {source_input}");
            continue;
        };
        let Some(target_node) = target.get_node_via_root(target_input).cloned() else {
            println!("[!] target lacked input {target_input}");
            continue;
        };

        if source_node.original.as_ref().unwrap() != target_node.original.as_ref().unwrap() {
            println!("input {source_input}->{target_input} has an original mismatch");
            println!("in source: {:?}", source_node.original.as_ref().unwrap());
            println!("in target: {:?}", target_node.original.as_ref().unwrap());

            println!();
            println!("If you choose to sync to source, I'll update flake.nix as well.");

            match choice("(S)ync to source, or (I)gnore?", &['s', 'i'])? {
                's' => {
                    sync_original(root, target_input, source_node, &target_node)?;
                    target = sync(&target, target_input, source_node)?;
                }
                'i' => {}
                _ => unreachable!(),
            }

            // If we synced here, the state is guaranteed to match (and our local `target_node`
            // is out-of-date).
            // If we didn't sync, the originals don't match and doing a timestamp check makes
            // no sense.
            continue;
        }

        // Originals match, so inequal locked implies there's just a rev mismatch.
        let source_locked = source_node.locked.as_ref().unwrap();
        let target_locked = target_node.locked.as_ref().unwrap();
        if source_locked != target_locked {
            let target_modified = target_locked.last_modified();
            let source_modified = source_locked.last_modified();
            match target_modified.cmp(&source_modified) {
                std::cmp::Ordering::Less => {
                    println!("input {source_input}->{target_input} is behind source");
                    print_modified(&source_modified, &target_modified);
                    match choice("(S)ync to source, or (I)gnore?", &['s', 'i'])? {
                        's' => target = sync(&target, target_input, source_node)?,
                        'i' => {}
                        _ => unreachable!(),
                    }
                }
                std::cmp::Ordering::Equal => panic!("wat"),
                std::cmp::Ordering::Greater => {
                    println!("input {source_input}->{target_input} is ahead of source (!)");
                    print_modified(&source_modified, &target_modified);
                    match choice("(S)ync to source, or (I)gnore?", &['s', 'i'])? {
                        's' => target = sync(&target, target_input, source_node)?,
                        'i' => {}
                        _ => unreachable!(),
                    }
                }
            }
            continue;
        }

        println!("input {source_input}->{target_input} is synced");
    }

    Ok(())
}

fn print_modified(source_modified: &Option<usize>, target_modified: &Option<usize>) {
    if let Some(source_modified) = source_modified {
        println!(
            "source: {source_modified} ({})",
            DateTime::from_timestamp(*source_modified as i64, 0).unwrap()
        );
    } else {
        println!("source: (no date)");
    }
    if let Some(target_modified) = target_modified {
        println!(
            "target: {target_modified} ({})",
            DateTime::from_timestamp(*target_modified as i64, 0).unwrap()
        );
    } else {
        println!("target: (no date)");
    }
}

pub(crate) fn choice(query: &str, choices: &[char]) -> Result<char> {
    loop {
        print!("{query} ");
        io::stdout().flush()?;
        let mut buf = String::new();
        _ = io::stdin().lock().read_line(&mut buf)?;
        buf.make_ascii_lowercase();
        let check = buf.trim();
        if check.len() != 1 {
            continue;
        }
        let check_char = check.chars().next().unwrap();
        for choice in choices {
            if check_char == *choice {
                return Ok(check_char);
            }
        }
    }
}

fn sync(target: &LockFile, input: &str, source_node: &LockNode) -> Result<LockFile> {
    let updated_target = target.update_node_via_root(input, source_node);
    updated_target.write()?;
    Ok(updated_target)
}

fn sync_original(
    root: &Path,
    target_input: &str,
    source_node: &LockNode,
    target_node: &LockNode,
) -> Result<()> {
    flake::replace_input_in_file(
        root,
        target_input,
        &format!("{:?}", target_node.original.as_ref().unwrap()),
        &format!("{:?}", source_node.original.as_ref().unwrap()),
    )
}