shine-cli 1.6.0

Give personal automation a reviewable lifecycle
Documentation
use crate::config::full_expand_with_home;
use crate::install_core::file_ops::{
    InstallOutcome, UninstallOutcome, install_bytes, install_bytes_admin, uninstall_entry,
    uninstall_entry_admin,
};
use crate::install_core::{AppEntry, AppInstallStrategy, apply_transforms, hash_content};
use crate::sys::resources::{
    DriverContext, ManagedFileReceipt, RECEIPT_VERSION, ResourceOutcome, SystemReceipt,
    config_string, optional_config_string,
};
use anyhow::{Context, Result, bail};
use std::path::PathBuf;

fn managed_file_desired(context: &DriverContext<'_>) -> Result<(PathBuf, Vec<u8>, Option<String>)> {
    let source = config_string(&context.item.config, "source")?;
    let target = config_string(&context.item.config, "target")?;
    let source = PathBuf::from(source);
    if source.is_absolute()
        || source
            .components()
            .any(|component| matches!(component, std::path::Component::ParentDir))
    {
        bail!("managed-file source must stay within its preset directory");
    }
    let source = context.preset_root.join(source);
    let canonical_root = std::fs::canonicalize(context.preset_root)
        .with_context(|| format!("reading preset root {}", context.preset_root.display()))?;
    let canonical_source =
        std::fs::canonicalize(&source).with_context(|| format!("reading {}", source.display()))?;
    if !canonical_source.starts_with(&canonical_root) {
        bail!("managed-file source must stay within its preset directory");
    }
    let target = PathBuf::from(
        full_expand_with_home(&target, &context.config.home_dir)
            .with_context(|| format!("expanding managed-file target `{target}`"))?,
    );
    if !target.is_absolute() {
        bail!("managed-file target must resolve to an absolute path");
    }
    let raw = std::fs::read(&canonical_source)
        .with_context(|| format!("reading {}", canonical_source.display()))?;
    let transforms = context
        .item
        .config
        .get("transforms")
        .and_then(toml::Value::as_array)
        .map(|values| {
            values
                .iter()
                .map(|value| {
                    value
                        .as_str()
                        .map(str::to_string)
                        .context("managed-file transforms must be strings")
                })
                .collect::<Result<Vec<_>>>()
        })
        .transpose()?
        .unwrap_or_default();
    let content = if transforms.is_empty() {
        raw
    } else {
        apply_transforms(&transforms, &raw, context.env)?
    };
    Ok((
        target,
        content,
        optional_config_string(&context.item.config, "restart_hint"),
    ))
}

pub(in crate::sys) async fn managed_file_up_to_date(
    context: &DriverContext<'_>,
    previous: Option<&SystemReceipt>,
) -> Result<bool> {
    let Some(SystemReceipt::ManagedFile(previous)) = previous else {
        return Ok(false);
    };
    let (destination, content, _restart_hint) = managed_file_desired(context)?;
    if previous.destination != destination || !destination.exists() {
        return Ok(false);
    }
    let current = tokio::fs::read(&destination)
        .await
        .with_context(|| format!("reading {}", destination.display()))?;
    if hash_content(&current) != previous.content_hash {
        return Ok(false);
    }
    Ok(current == content)
}

pub(in crate::sys) async fn apply_managed_file(
    context: &DriverContext<'_>,
    previous: Option<&SystemReceipt>,
) -> Result<ResourceOutcome> {
    let (destination, content, restart_hint) = managed_file_desired(context)?;
    let previous = match previous {
        Some(SystemReceipt::ManagedFile(receipt)) => Some(receipt),
        Some(other) => bail!("managed-file received {:?} receipt", other.driver()),
        None => None,
    };
    if context.dry_run {
        return Ok(ResourceOutcome {
            changed: true,
            detail: destination.display().to_string(),
            receipt: None,
            restart_hint,
        });
    }
    if let Some(previous) = previous {
        if previous.destination != destination {
            remove_managed_file(previous, false).await?;
        } else if destination.exists() {
            let current = tokio::fs::read(&destination).await?;
            if hash_content(&current) != previous.content_hash {
                bail!(
                    "managed file {} was modified; keeping user content",
                    destination.display()
                );
            }
            if current == content {
                return Ok(ResourceOutcome {
                    changed: false,
                    detail: destination.display().to_string(),
                    receipt: Some(SystemReceipt::ManagedFile(previous.clone())),
                    restart_hint: None,
                });
            }
        }
    }
    let is_managed = previous.is_some_and(|receipt| receipt.destination == destination);
    let outcome = if context.item.requires_admin {
        install_bytes_admin(&content, &destination, is_managed, false, true).await?
    } else {
        install_bytes(&content, &destination, is_managed, false, true).await?
    };
    let changed = !matches!(outcome, InstallOutcome::AlreadyManaged);
    let backup = match outcome {
        InstallOutcome::BackedUpAndInstalled { backup, .. } => Some(backup),
        _ => previous.and_then(|receipt| receipt.backup.clone()),
    };
    let receipt = ManagedFileReceipt {
        version: RECEIPT_VERSION,
        destination: destination.clone(),
        backup,
        content_hash: hash_content(&content),
        privileged: context.item.requires_admin,
        restart_hint: restart_hint.clone(),
    };
    Ok(ResourceOutcome {
        changed,
        detail: destination.display().to_string(),
        receipt: Some(SystemReceipt::ManagedFile(receipt)),
        restart_hint,
    })
}

pub(in crate::sys) async fn remove_managed_file(
    receipt: &ManagedFileReceipt,
    dry_run: bool,
) -> Result<ResourceOutcome> {
    let entry = AppEntry {
        source: "sys/managed-file".to_string(),
        destination: receipt.destination.clone(),
        backup: receipt.backup.clone(),
        content_hash: receipt.content_hash,
        install_strategy: AppInstallStrategy::Copy,
        uses_env: false,
        requires_admin: receipt.privileged,
    };
    let outcome = if receipt.privileged {
        uninstall_entry_admin(&entry, dry_run, false).await?
    } else {
        uninstall_entry(&entry, dry_run, false).await?
    };
    if matches!(outcome, UninstallOutcome::UserModified) {
        bail!(
            "managed file {} was modified; keeping user content",
            receipt.destination.display()
        );
    }
    Ok(ResourceOutcome {
        changed: !matches!(outcome, UninstallOutcome::NotFound),
        detail: receipt.destination.display().to_string(),
        receipt: None,
        restart_hint: receipt.restart_hint.clone(),
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::Config;
    use crate::sys::resources::{BuiltinDriver, SystemDriver};
    use crate::sys::{SysDriverKind, SysItem, SysItemMode};
    use std::collections::BTreeMap;
    use std::path::Path;

    async fn make_temp_dir() -> PathBuf {
        crate::test_support::make_temp_dir("shine-resource").await
    }

    fn managed_file_item(source: &str, target: &Path) -> SysItem {
        let mut config = toml::Table::new();
        config.insert(
            "source".to_string(),
            toml::Value::String(source.to_string()),
        );
        config.insert(
            "target".to_string(),
            toml::Value::String(target.display().to_string()),
        );
        config.insert(
            "restart_hint".to_string(),
            toml::Value::String("Restart sample".to_string()),
        );
        SysItem {
            id: "sample-file".to_string(),
            label: "Sample file".to_string(),
            description: String::new(),
            default: false,
            mode: SysItemMode::Managed,
            requires_admin: false,
            required_env: Vec::new(),
            driver: SysDriverKind::ManagedFile,
            config,
            detect: None,
            install: None,
            shell: Vec::new(),
        }
    }

    #[tokio::test]
    async fn managed_file_backs_up_converges_and_restores() {
        let dir = make_temp_dir().await;
        let source = dir.join("source.txt");
        let destination = dir.join("destination.txt");
        tokio::fs::write(&source, "desired").await.unwrap();
        tokio::fs::write(&destination, "original").await.unwrap();
        let config = Config::new_for_test(&dir);
        let item = managed_file_item("source.txt", &destination);
        let env = BTreeMap::new();
        let context = DriverContext {
            config: &config,
            os_id: "fakeos",
            item: &item,
            preset_root: &dir,
            env: &env,
            dry_run: false,
        };
        let driver = BuiltinDriver::new(SysDriverKind::ManagedFile);

        let installed = driver.apply(&context, None).await.unwrap();
        assert!(installed.changed);
        assert_eq!(
            tokio::fs::read_to_string(&destination).await.unwrap(),
            "desired"
        );
        assert_eq!(installed.restart_hint.as_deref(), Some("Restart sample"));
        let receipt = installed.receipt.unwrap();

        let unchanged = driver.apply(&context, Some(&receipt)).await.unwrap();
        assert!(!unchanged.changed);
        assert!(unchanged.restart_hint.is_none());

        tokio::fs::write(&destination, "user edit").await.unwrap();
        let error = driver.apply(&context, Some(&receipt)).await.unwrap_err();
        assert!(error.to_string().contains("modified"));

        tokio::fs::write(&destination, "desired").await.unwrap();
        let removed = driver
            .remove(Some(&context), &receipt, false)
            .await
            .unwrap();
        assert!(removed.changed);
        assert_eq!(
            tokio::fs::read_to_string(&destination).await.unwrap(),
            "original"
        );

        tokio::fs::remove_dir_all(&dir).await.unwrap();
    }

    #[tokio::test]
    async fn managed_file_is_up_to_date_matches_apply_convergence() {
        let dir = make_temp_dir().await;
        let source = dir.join("source.txt");
        let destination = dir.join("destination.txt");
        tokio::fs::write(&source, "desired").await.unwrap();
        let config = Config::new_for_test(&dir);
        let item = managed_file_item("source.txt", &destination);
        let env = BTreeMap::new();
        let context = DriverContext {
            config: &config,
            os_id: "fakeos",
            item: &item,
            preset_root: &dir,
            env: &env,
            dry_run: false,
        };
        let driver = BuiltinDriver::new(SysDriverKind::ManagedFile);

        assert!(!driver.is_up_to_date(&context, None).await.unwrap());

        let installed = driver.apply(&context, None).await.unwrap();
        let receipt = installed.receipt.unwrap();
        assert!(
            driver
                .is_up_to_date(&context, Some(&receipt))
                .await
                .unwrap()
        );

        tokio::fs::write(&source, "changed").await.unwrap();
        assert!(
            !driver
                .is_up_to_date(&context, Some(&receipt))
                .await
                .unwrap()
        );

        tokio::fs::remove_dir_all(&dir).await.unwrap();
    }
}