mk 0.7.12

Yet another simple task runner 🦀
Documentation
use std::fs;
use std::path::Path;

use clap::Args;
use mk_lib::file::ToUtf8 as _;
use mk_lib::secrets::{
  read_vault_gpg_key_id,
  write_vault_meta,
  SecretBackend,
  SecretConfig,
  SecretSettings,
  VaultMeta,
};
use serde_yaml::{
  Mapping,
  Value,
};

use crate::secrets::context::Context;

#[derive(Debug, Args)]
pub struct InitVault {
  #[arg(short, long, help = "The path to the secret vault")]
  vault_location: Option<String>,

  #[arg(short, long, help = "The key name", conflicts_with = "gpg_key_id")]
  key_name: Option<String>,

  #[arg(
    long,
    conflicts_with = "key_name",
    help = "GPG key ID or fingerprint to associate with this vault. When set, all vault commands (store, show, export, …) will use gpg automatically without needing the --gpg-key-id flag. Cannot be combined with --key-name."
  )]
  gpg_key_id: Option<String>,

  #[arg(
    long,
    help = "Update the active config file with a root secrets block after vault initialization"
  )]
  write_config: bool,
}

impl InitVault {
  pub fn execute(&self, context: &Context) -> anyhow::Result<()> {
    let mut secret_config = context.resolved_config();
    if let Some(vault_location) = &self.vault_location {
      secret_config.vault_location = Path::new(vault_location).to_path_buf();
    }
    let gpg_key_id = self
      .gpg_key_id
      .clone()
      .or_else(|| secret_config.gpg_key_id.clone());
    secret_config.backend = if gpg_key_id.is_some() {
      SecretBackend::Gpg
    } else {
      SecretBackend::BuiltInPgp
    };
    secret_config.gpg_key_id = gpg_key_id.clone();
    let vault_location = secret_config.vault_location.to_string_lossy().to_string();

    if self.write_config {
      ensure_config_mutation_supported(context.active_config_path())?;
    }

    assert!(!vault_location.is_empty(), "Vault location must be provided");

    let path = Path::new(&vault_location);
    if path.exists() {
      println!("Vault already exists at {vault_location}");
    } else {
      fs::create_dir_all(path)?;
      println!("Vault created at {vault_location}");
    }

    if let Some(id) = &gpg_key_id {
      if let Some(existing_id) = read_vault_gpg_key_id(path) {
        if existing_id != *id {
          eprintln!(
            "Warning: vault GPG key changed from '{}' to '{}'. Overwriting metadata.",
            existing_id, id
          );
        }
      }
      println!("Vault configured to use GPG key: {id}");
    }

    write_vault_meta(path, &vault_meta_from_config(&secret_config))?;

    if self.write_config {
      let config_path = context
        .active_config_path()
        .ok_or_else(|| anyhow::anyhow!("No active config file available for --write-config"))?;
      let secrets = secret_settings_for_config_write(context, &secret_config);
      update_yaml_config(config_path, &secrets)?;
      println!(
        "Config updated at {}",
        config_path.to_utf8().unwrap_or("<non-utf8-path>")
      );
    }

    Ok(())
  }
}

fn ensure_config_mutation_supported(config_path: Option<&Path>) -> anyhow::Result<()> {
  let Some(config_path) = config_path else {
    anyhow::bail!("No active config file available for --write-config");
  };

  match config_path.extension().and_then(|ext| ext.to_str()) {
    Some("yaml") | Some("yml") => Ok(()),
    Some(ext) => anyhow::bail!(
      "Config mutation via --write-config only supports YAML files. Refusing to update .{} config: {}",
      ext,
      config_path.to_utf8().unwrap_or("<non-utf8-path>")
    ),
    None => anyhow::bail!(
      "Config mutation via --write-config only supports YAML files. Refusing to update config without extension: {}",
      config_path.to_utf8().unwrap_or("<non-utf8-path>")
    ),
  }
}

fn vault_meta_from_config(secret_config: &SecretConfig) -> VaultMeta {
  match secret_config.backend {
    SecretBackend::Gpg => VaultMeta {
      backend: Some(SecretBackend::Gpg),
      keys_location: None,
      key_name: None,
      gpg_key_id: secret_config.gpg_key_id.clone(),
    },
    SecretBackend::BuiltInPgp => VaultMeta {
      backend: Some(SecretBackend::BuiltInPgp),
      keys_location: Some(secret_config.keys_location.to_string_lossy().to_string()),
      key_name: Some(secret_config.key_name.clone()),
      gpg_key_id: None,
    },
  }
}

fn secret_settings_for_config_write(context: &Context, secret_config: &SecretConfig) -> SecretSettings {
  let secrets_path = context
    .root_settings()
    .and_then(|settings| settings.secrets_path.clone());
  let vault_location = path_for_config(context.config_base_dir(), &secret_config.vault_location);

  match secret_config.backend {
    SecretBackend::Gpg => SecretSettings {
      backend: Some(SecretBackend::Gpg),
      vault_location: Some(vault_location),
      keys_location: None,
      key_name: None,
      gpg_key_id: secret_config.gpg_key_id.clone(),
      secrets_path,
    },
    SecretBackend::BuiltInPgp => SecretSettings {
      backend: Some(SecretBackend::BuiltInPgp),
      vault_location: Some(vault_location),
      keys_location: Some(path_for_config(
        context.config_base_dir(),
        &secret_config.keys_location,
      )),
      key_name: Some(secret_config.key_name.clone()),
      gpg_key_id: None,
      secrets_path,
    },
  }
}

fn path_for_config(base_dir: &Path, path: &Path) -> String {
  if let Ok(relative) = path.strip_prefix(base_dir) {
    let rendered = relative.to_string_lossy().replace('\\', "/");
    if !rendered.is_empty() {
      return rendered;
    }
  }

  path.to_string_lossy().replace('\\', "/")
}

fn update_yaml_config(config_path: &Path, secrets: &SecretSettings) -> anyhow::Result<()> {
  let (mut value, prefix) = if config_path.exists() {
    let contents = fs::read_to_string(config_path)?;
    let prefix = leading_yaml_preamble(&contents);
    let trimmed = contents.trim();
    let value = if trimmed.is_empty() {
      Value::Mapping(Mapping::new())
    } else {
      serde_yaml::from_str::<Value>(&contents)?
    };
    (value, prefix)
  } else {
    (Value::Mapping(Mapping::new()), String::new())
  };

  let mapping = value
    .as_mapping_mut()
    .ok_or_else(|| anyhow::anyhow!("Root config must be a YAML mapping to support --write-config"))?;

  remove_legacy_secret_fields(mapping);
  mapping.insert(
    Value::String(String::from("secrets")),
    secret_settings_to_yaml_value(secrets),
  );
  mapping
    .entry(Value::String(String::from("tasks")))
    .or_insert_with(|| Value::Mapping(Mapping::new()));

  let mut rendered = String::new();
  if !prefix.is_empty() {
    rendered.push_str(&prefix);
    if !prefix.ends_with('\n') {
      rendered.push('\n');
    }
  }
  rendered.push_str(&serde_yaml::to_string(&value)?);
  fs::write(config_path, rendered)?;
  Ok(())
}

fn leading_yaml_preamble(contents: &str) -> String {
  let mut prefix = String::new();
  for line in contents.lines() {
    let trimmed = line.trim();
    if trimmed.is_empty() || trimmed.starts_with('#') {
      prefix.push_str(line);
      prefix.push('\n');
      continue;
    }
    break;
  }
  prefix
}

fn remove_legacy_secret_fields(mapping: &mut Mapping) {
  for key in [
    "vault_location",
    "keys_location",
    "key_name",
    "gpg_key_id",
    "secrets_path",
  ] {
    mapping.remove(Value::String(key.to_string()));
  }
}

fn secret_settings_to_yaml_value(settings: &SecretSettings) -> Value {
  let mut mapping = Mapping::new();

  if let Some(backend) = &settings.backend {
    let backend = match backend {
      SecretBackend::BuiltInPgp => "built_in_pgp",
      SecretBackend::Gpg => "gpg",
    };
    mapping.insert(
      Value::String(String::from("backend")),
      Value::String(backend.to_string()),
    );
  }
  if let Some(vault_location) = &settings.vault_location {
    mapping.insert(
      Value::String(String::from("vault_location")),
      Value::String(vault_location.clone()),
    );
  }
  if let Some(keys_location) = &settings.keys_location {
    mapping.insert(
      Value::String(String::from("keys_location")),
      Value::String(keys_location.clone()),
    );
  }
  if let Some(key_name) = &settings.key_name {
    mapping.insert(
      Value::String(String::from("key_name")),
      Value::String(key_name.clone()),
    );
  }
  if let Some(gpg_key_id) = &settings.gpg_key_id {
    mapping.insert(
      Value::String(String::from("gpg_key_id")),
      Value::String(gpg_key_id.clone()),
    );
  }
  if let Some(secrets_path) = &settings.secrets_path {
    mapping.insert(
      Value::String(String::from("secrets_path")),
      Value::Sequence(
        secrets_path
          .iter()
          .map(|path| Value::String(path.clone()))
          .collect::<Vec<_>>(),
      ),
    );
  }

  Value::Mapping(mapping)
}