iqan 0.3.0

Sync Nix flake pins
use anyhow::{Context, Result, anyhow};
use std::{
    collections::HashMap,
    path::Path,
    process::{Command, Stdio},
};

use crate::{
    choice, flake,
    lockfile::{
        LockFile, LockNodeLocked, LockNodeLockedTarball, LockNodeOriginal, LockNodeOriginalTarball,
    },
};

const BUCKET: &str = "https://nix-releases.s3.amazonaws.com/";
const RELEASES: &str = "https://releases.nixos.org/";

#[derive(Debug, PartialEq)]
pub(crate) struct Channel {
    name: String,
    prefix: String,
}

impl Channel {
    pub(crate) fn url(&self) -> String {
        format!("https://channels.nixos.org/{}/nixexprs.tar.zst", self.name)
    }
}

pub(crate) fn channel_for_ref(r#ref: &str) -> Option<Channel> {
    let prefix = if let Some(rest) = r#ref.strip_prefix("nixos-") {
        let release = rest.strip_suffix("-small").unwrap_or(rest);
        if release != "unstable" && !is_version(release) {
            return None;
        }
        format!("nixos/{rest}/")
    } else if let Some(rest) = r#ref.strip_prefix("nixpkgs-") {
        if rest == "unstable" {
            "nixpkgs/".to_string()
        } else if is_version(rest.strip_suffix("-darwin")?) {
            format!("nixpkgs/{rest}/")
        } else {
            return None;
        }
    } else {
        return None;
    };

    Some(Channel {
        name: r#ref.to_string(),
        prefix,
    })
}

fn is_version(version: &str) -> bool {
    let Some((year, month)) = version.split_once('.') else {
        return false;
    };
    year.len() == 2
        && month.len() == 2
        && year
            .chars()
            .chain(month.chars())
            .all(|c| c.is_ascii_digit())
}

#[derive(Debug, PartialEq)]
pub(crate) struct Release {
    name: String,
    url: String,
}

#[derive(Default)]
struct Bucket {
    directories: HashMap<String, Vec<String>>,
}

impl Bucket {
    fn find_release(&mut self, channel: &Channel, rev: &str) -> Result<Option<Release>> {
        let directories = match self.directories.get(&channel.prefix) {
            Some(directories) => directories,
            None => {
                let directories = list(&channel.prefix, true)?;
                self.directories
                    .entry(channel.prefix.clone())
                    .or_insert(directories)
            }
        };

        let Some(directory) = select_release_directory(directories, rev) else {
            return Ok(None);
        };
        let directory = directory.to_string();

        let nixexprs = select_nixexprs(&list(&directory, false)?)
            .with_context(|| format!("release {directory} has no nixexprs tarball"))?
            .to_string();

        Ok(Some(Release {
            name: directory
                .trim_end_matches('/')
                .rsplit('/')
                .next()
                .unwrap()
                .to_string(),
            url: format!("{RELEASES}{nixexprs}"),
        }))
    }
}

fn short_rev(rev: &str) -> &str {
    &rev[..12]
}

fn select_release_directory<'a>(directories: &'a [String], rev: &str) -> Option<&'a str> {
    let suffix = format!(".{}/", short_rev(rev));
    directories
        .iter()
        .find(|directory| directory.ends_with(&suffix))
        .map(String::as_str)
}

fn select_nixexprs(keys: &[String]) -> Option<&str> {
    for extension in ["zst", "xz"] {
        let suffix = format!("/nixexprs.tar.{extension}");
        if let Some(key) = keys.iter().find(|key| key.ends_with(&suffix)) {
            return Some(key);
        }
    }
    None
}

fn list(prefix: &str, directories_only: bool) -> Result<Vec<String>> {
    let mut entries = vec![];
    let mut continuation: Option<String> = None;

    loop {
        let mut request = ureq::get(BUCKET)
            .query("list-type", "2")
            .query("prefix", prefix)
            .query("max-keys", "1000");
        if directories_only {
            request = request.query("delimiter", "/");
        }
        if let Some(continuation) = &continuation {
            request = request.query("continuation-token", continuation);
        }

        let body = request
            .call()
            .with_context(|| format!("failed to list {prefix} in releases bucket"))?
            .body_mut()
            .read_to_string()?;

        let (mut page, next) = parse_listing(&body, directories_only)?;
        entries.append(&mut page);

        continuation = next;
        if continuation.is_none() {
            return Ok(entries);
        }
    }
}

fn parse_listing(body: &str, directories_only: bool) -> Result<(Vec<String>, Option<String>)> {
    let document = roxmltree::Document::parse(body)?;
    let root = document.root_element();

    let (container, name) = if directories_only {
        ("CommonPrefixes", "Prefix")
    } else {
        ("Contents", "Key")
    };

    let entries = root
        .children()
        .filter(|node| node.has_tag_name(container))
        .map(|node| {
            node.children()
                .find(|child| child.has_tag_name(name))
                .and_then(|child| child.text())
                .map(str::to_string)
                .with_context(|| format!("{container} without {name}"))
        })
        .collect::<Result<Vec<_>>>()?;

    let continuation = root
        .children()
        .find(|node| node.has_tag_name("NextContinuationToken"))
        .and_then(|node| node.text())
        .map(str::to_string);

    Ok((entries, continuation))
}

fn nar_hash(url: &str) -> Result<String> {
    let output = Command::new("nix")
        .args(["flake", "prefetch", "--json", url])
        .stderr(Stdio::inherit())
        .output()
        .context("failed to run nix flake prefetch")?;
    if !output.status.success() {
        return Err(anyhow!("nix flake prefetch {url} failed"));
    }

    let prefetched: serde_json::Value = serde_json::from_slice(&output.stdout)?;
    prefetched["hash"]
        .as_str()
        .map(str::to_string)
        .context("nix flake prefetch gave no hash")
}

pub(crate) fn convert(root: &Path) -> Result<()> {
    let root_display = root.to_string_lossy();
    let mut lockfile = LockFile::from_root(root)?;
    let mut bucket = Bucket::default();

    println!("Flake: {root_display}");
    println!("-------{}", "-".repeat(root_display.len()));

    for input in lockfile.get_inputs() {
        let Some(node) = lockfile.get_node_via_root(&input).cloned() else {
            continue;
        };
        let (Some(LockNodeOriginal::GitHub(original)), Some(LockNodeLocked::GitHub(locked))) =
            (&node.original, &node.locked)
        else {
            continue;
        };
        if !original.owner.eq_ignore_ascii_case("nixos")
            || !original.repo.eq_ignore_ascii_case("nixpkgs")
        {
            continue;
        }

        if original.rev.is_some() {
            println!("[!] input {input} pins a rev; skipping");
            continue;
        }
        let Some(r#ref) = &original.r#ref else {
            println!("[!] input {input} tracks the default branch; skipping");
            continue;
        };
        let Some(channel) = channel_for_ref(r#ref) else {
            println!("[!] input {input} tracks {ref}, which isn't a channel; skipping");
            continue;
        };
        let Some(release) = bucket.find_release(&channel, &locked.rev)? else {
            println!(
                "[!] input {input} is locked to {}, which isn't a {ref} release; skipping",
                locked.rev
            );
            continue;
        };

        let existing = format!("{:?}", node.original.as_ref().unwrap());
        let channel_url = channel.url();
        println!();
        println!("input {input} can use the {ref} channel tarball");
        println!("in flake.nix: {existing} -> {channel_url}");
        println!("release: {} (rev unchanged)", release.name);
        println!("locked:  {}", release.url);

        match choice("(C)onvert to tarball, or (I)gnore?", &['c', 'i'])? {
            'c' => {}
            'i' => continue,
            _ => unreachable!(),
        }

        let nar_hash = nar_hash(&release.url)?;

        let mut converted = node.clone();
        converted.locked = Some(LockNodeLocked::Tarball(LockNodeLockedTarball {
            last_modified: locked.last_modified,
            nar_hash,
            rev: Some(locked.rev.clone()),
            rev_count: None,
            url: release.url,
        }));
        converted.original = Some(LockNodeOriginal::Tarball(LockNodeOriginalTarball {
            url: channel_url.clone(),
        }));

        flake::replace_input_in_file(root, &input, &existing, &channel_url)?;
        lockfile = lockfile.update_node_via_root(&input, &converted);
        lockfile.write()?;
    }

    Ok(())
}

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

    #[test]
    fn channels_map_to_bucket_prefixes() {
        for (r#ref, prefix) in [
            ("nixos-unstable", "nixos/unstable/"),
            ("nixos-unstable-small", "nixos/unstable-small/"),
            ("nixos-26.05", "nixos/26.05/"),
            ("nixos-25.11-small", "nixos/25.11-small/"),
            ("nixpkgs-unstable", "nixpkgs/"),
            ("nixpkgs-26.05-darwin", "nixpkgs/26.05-darwin/"),
        ] {
            let channel = channel_for_ref(r#ref).unwrap();
            assert_eq!(prefix, channel.prefix);
            assert_eq!(
                format!("https://channels.nixos.org/{ref}/nixexprs.tar.zst"),
                channel.url()
            );
        }
    }

    #[test]
    fn non_channel_refs_are_rejected() {
        for r#ref in [
            "master",
            "release-25.11",
            "nixos-unstable-tiny",
            "nixos-2026.05",
            "nixpkgs-26.05",
            "nixpkgs-staging",
        ] {
            assert_eq!(None, channel_for_ref(r#ref));
        }
    }

    const DIRECTORIES: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>nix-releases</Name><Prefix>nixos/unstable/</Prefix><NextContinuationToken>1BEBEXw7nIQ==</NextContinuationToken><KeyCount>2</KeyCount><MaxKeys>2</MaxKeys><Delimiter>/</Delimiter><IsTruncated>true</IsTruncated><CommonPrefixes><Prefix>nixos/unstable/nixos-26.05pre924538.3497aa5c9457/</Prefix></CommonPrefixes><CommonPrefixes><Prefix>nixos/unstable/nixos-26.11pre1069856.d6524aaca2ff/</Prefix></CommonPrefixes></ListBucketResult>"#;

    const KEYS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>nix-releases</Name><Prefix>nixos/unstable/nixos-26.11pre1069856.d6524aaca2ff/</Prefix><KeyCount>3</KeyCount><MaxKeys>1000</MaxKeys><IsTruncated>false</IsTruncated><Contents><Key>nixos/unstable/nixos-26.11pre1069856.d6524aaca2ff/git-revision</Key><Size>40</Size></Contents><Contents><Key>nixos/unstable/nixos-26.11pre1069856.d6524aaca2ff/nixexprs.tar.xz</Key><Size>1</Size></Contents><Contents><Key>nixos/unstable/nixos-26.11pre1069856.d6524aaca2ff/nixexprs.tar.zst</Key><Size>1</Size></Contents></ListBucketResult>"#;

    #[test]
    fn directory_listings_yield_common_prefixes_and_continuation() {
        let (directories, continuation) = parse_listing(DIRECTORIES, true).unwrap();
        assert_eq!(
            vec![
                "nixos/unstable/nixos-26.05pre924538.3497aa5c9457/",
                "nixos/unstable/nixos-26.11pre1069856.d6524aaca2ff/"
            ],
            directories
        );
        assert_eq!(Some("1BEBEXw7nIQ==".to_string()), continuation);
    }

    #[test]
    fn key_listings_yield_keys_and_no_continuation() {
        let (keys, continuation) = parse_listing(KEYS, false).unwrap();
        assert_eq!(3, keys.len());
        assert_eq!(None, continuation);
        assert_eq!(
            Some("nixos/unstable/nixos-26.11pre1069856.d6524aaca2ff/nixexprs.tar.zst"),
            select_nixexprs(&keys)
        );
    }

    #[test]
    fn releases_without_zstd_fall_back_to_xz() {
        let keys = vec![
            "nixos/unstable/nixos-26.05pre924538.3497aa5c9457/git-revision".to_string(),
            "nixos/unstable/nixos-26.05pre924538.3497aa5c9457/nixexprs.tar.xz".to_string(),
        ];
        assert_eq!(
            Some("nixos/unstable/nixos-26.05pre924538.3497aa5c9457/nixexprs.tar.xz"),
            select_nixexprs(&keys)
        );
    }

    #[test]
    fn releases_are_selected_by_short_rev() {
        let (directories, _) = parse_listing(DIRECTORIES, true).unwrap();
        assert_eq!(
            Some("nixos/unstable/nixos-26.05pre924538.3497aa5c9457/"),
            select_release_directory(&directories, "3497aa5c9457a9d88d71fa93a4a8368816fbeeba")
        );
        assert_eq!(
            None,
            select_release_directory(&directories, "0000000000000000000000000000000000000000")
        );
    }
}