iqan 0.3.0

Sync Nix flake pins
use anyhow::{Context, Result, anyhow};
use rnix::ast::{self, HasEntry};
use rowan::ast::AstNode;
use std::{ops::Range, path::Path};

pub(crate) fn replace_input_in_file(
    root: &Path,
    input_name: &str,
    input_existing: &str,
    input_new: &str,
) -> Result<()> {
    let path = root.join("flake.nix");
    let content = std::fs::read_to_string(&path)
        .with_context(|| format!("failed to read original flake.nix from {path:?}"))?;
    let updated = replace_input(&content, input_name, input_existing, input_new)?;
    std::fs::write(&path, updated)
        .with_context(|| format!("failed to write new flake.nix at {path:?}"))?;
    Ok(())
}

pub(crate) fn replace_input(
    flake_nix_content: &str,
    input_name: &str,
    input_existing: &str,
    input_new: &str,
) -> Result<String> {
    let tree = rnix::Root::parse(flake_nix_content).ok()?;

    let root = tree.expr().context("root")?;
    let ast::Expr::AttrSet(ref set) = root else {
        return Err(anyhow!("root not attrset"));
    };

    let url_str = locate_url(set, &["inputs", input_name, "url"])?
        .ok_or_else(|| anyhow!("input url not found"))?;

    let url_parts = url_str.normalized_parts();
    if url_parts.len() != 1 {
        return Err(anyhow!("input url wasn't a simple string"));
    }
    let ast::InterpolPart::Literal(url) = &url_parts[0] else {
        return Err(anyhow!("input url wasn't a simple string"));
    };
    if url != input_existing {
        // TODO: Allow ignoring, or parse both sides to match.
        return Err(anyhow!(
            "input url value {url} didn't match expected {input_existing}"
        ));
    }

    replace_input_value(
        flake_nix_content,
        url_str.syntax().text_range().into(),
        input_new,
    )
}

fn locate_url(set: &ast::AttrSet, path: &[&str]) -> Result<Option<ast::Str>> {
    for entry in set.entries() {
        let ast::Entry::AttrpathValue(attrpath_value) = entry else {
            continue;
        };
        let attrs = attrpath_value
            .attrpath()
            .context("attrpath")?
            .attrs()
            .map(|a| a.to_string())
            .collect::<Vec<_>>();
        if attrs.len() > path.len() || attrs != path[..attrs.len()] {
            continue;
        }

        let value = attrpath_value
            .value()
            .with_context(|| format!("{} empty?", attrs.join(".")))?;
        let rest = &path[attrs.len()..];

        if rest.is_empty() {
            let ast::Expr::Str(url_str) = value else {
                return Err(anyhow!("input url not string"));
            };
            return Ok(Some(url_str));
        }

        let ast::Expr::AttrSet(ref inner) = value else {
            return Err(anyhow!("{} not attrset", attrs.join(".")));
        };
        if let Some(url_str) = locate_url(inner, rest)? {
            return Ok(Some(url_str));
        }
    }

    Ok(None)
}

fn replace_input_value(subject: &str, tr: Range<usize>, replacement: &str) -> Result<String> {
    if subject.as_bytes()[tr.start] != b'"' {
        return Err(anyhow!(
            "replacement subject didn't start with double quote"
        ));
    }
    if subject.as_bytes()[tr.end - 1] != b'"' {
        return Err(anyhow!("replacement subject didn't end with double quote"));
    }

    let mut subject = subject.to_string();
    subject.drain((tr.start + 1)..(tr.end - 1));
    subject.insert_str(tr.start + 1, replacement);
    Ok(subject)
}

#[test]
fn does_the_replacement_ok() {
    assert_eq!(
        CHOG_FLAKE_NIX.replace(CHOG_NIXPKGS, CHOG_DELIGHT),
        replace_input(CHOG_FLAKE_NIX, "nixpkgs", CHOG_NIXPKGS, CHOG_DELIGHT).unwrap()
    );
}

#[cfg(test)]
const CHOG_NIXPKGS: &str = "github:NixOS/nixpkgs/cb82756ecc37fa623f8cf3e88854f9bf7f64af93";

#[cfg(test)]
const CHOG_DELIGHT: &str = "delighted:nossa";

#[cfg(test)]
const CHOG_FLAKE_NIX: &str = r#"
{
  description = "chog";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/cb82756ecc37fa623f8cf3e88854f9bf7f64af93";
  };

  outputs =
    {
      self,
      nixpkgs,
    }:
    let
      systems = [
        "aarch64-darwin"
        "aarch64-linux"
        "x86_64-darwin"
        "x86_64-linux"
      ];
      eachSystem = nixpkgs.lib.genAttrs systems;
      partsForSystem = system: rec {
        pkgs = nixpkgs.legacyPackages.${system};
        erlang = pkgs.beam_minimal.interpreters.erlang_27;
        beamPackages = pkgs.beam_minimal.packagesWith erlang;
        elixir = beamPackages.elixir_1_18;
      };
    in
    {
      formatter = eachSystem (system: nixpkgs.legacyPackages.${system}.nixfmt-rfc-style);

      packages = eachSystem (
        system:
        let
          inherit (partsForSystem system)
            pkgs
            erlang
            beamPackages
            elixir
            ;
          package = pkgs.callPackage ./nix/package.nix {
            inherit
              beamPackages
              erlang
              elixir
              ;
          };
        in
        rec {
          default = chog;

          chog = package;

          chog-docker = pkgs.callPackage ./nix/docker.nix {
            chog-rev = self.rev or "dev";
            inherit chog;
          };
        }
      );

      devShell = eachSystem (
        system: import ./nix/shell.nix (partsForSystem system // { mixEnv = "dev"; })
      );
    };
}
"#;

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

    const EXISTING: &str = "github:NixOS/nixpkgs/nixos-26.05";
    const NEW: &str = "https://channels.nixos.org/nixos-26.05/nixexprs.tar.zst";

    fn replaced(flake_nix: &str) -> String {
        replace_input(flake_nix, "nixpkgs", EXISTING, NEW).unwrap()
    }

    #[test]
    fn attrset_form() {
        let flake_nix = r#"{
  inputs = {
    fenix.url = "github:nix-community/fenix";
    nixpkgs = {
      url = "github:NixOS/nixpkgs/nixos-26.05";
      flake = true;
    };
  };
}
"#;
        assert_eq!(flake_nix.replace(EXISTING, NEW), replaced(flake_nix));
    }

    #[test]
    fn top_level_path_form() {
        let flake_nix = r#"{
  inputs.fenix.url = "github:nix-community/fenix";
  inputs.fenix.inputs.nixpkgs.follows = "nixpkgs";
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
}
"#;
        assert_eq!(flake_nix.replace(EXISTING, NEW), replaced(flake_nix));
    }

    #[test]
    fn top_level_attrset_form() {
        let flake_nix = r#"{
  inputs.nixpkgs = {
    url = "github:NixOS/nixpkgs/nixos-26.05";
  };
}
"#;
        assert_eq!(flake_nix.replace(EXISTING, NEW), replaced(flake_nix));
    }

    #[test]
    fn follows_only_input_is_not_a_url() {
        let flake_nix = r#"{
  inputs = {
    nixpkgs.follows = "nixpkgs-stable";
  };
}
"#;
        assert_eq!(
            "input url not found",
            replace_input(flake_nix, "nixpkgs", EXISTING, NEW)
                .unwrap_err()
                .to_string()
        );
    }

    #[test]
    fn absent_input_is_reported() {
        let flake_nix = r#"{
  inputs.fenix.url = "github:nix-community/fenix";
}
"#;
        assert_eq!(
            "input url not found",
            replace_input(flake_nix, "nixpkgs", EXISTING, NEW)
                .unwrap_err()
                .to_string()
        );
    }
}