shine-cli 1.1.0

Cross-platform CLI for managed shell commands, app configs, and machine setup
Documentation
use crate::config::Config;
use anyhow::{Context, Result, bail};
use std::path::PathBuf;

pub fn resolve_destination(
    annotation: Option<&str>,
    category: &str,
    relative_path: &str,
    config: &Config,
) -> Result<PathBuf> {
    let raw = match annotation {
        Some(ann) => ann.to_string(),
        None => {
            let root = config.app_default_dest_root();
            format!("{}/{}/{}", root.display(), category, relative_path)
        }
    };

    let expanded = crate::config::full_expand_with_home(&raw, &config.home_dir)
        .with_context(|| format!("failed to expand destination path: {raw}"))?;

    let path = PathBuf::from(&expanded);

    if !path.is_absolute() {
        bail!("destination path must be absolute after expansion, got: {expanded}");
    }

    if path
        .components()
        .any(|c| c == std::path::Component::ParentDir)
    {
        bail!("destination path must not contain '..': {expanded}");
    }

    Ok(path)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::Config;
    use std::path::Path;

    fn test_config(home: &Path) -> Config {
        Config::new_for_test(home)
    }

    #[test]
    fn resolves_annotation_with_tilde() {
        let home = std::env::temp_dir().join("shine-ann-test");
        let config = test_config(&home);
        let result =
            resolve_destination(Some("~/.config/foo/bar.toml"), "foo", "bar.toml", &config)
                .unwrap();
        assert!(result.is_absolute());
        assert!(result.ends_with("bar.toml"));
    }

    #[test]
    fn resolves_absolute_annotation() {
        let home = std::env::temp_dir().join("shine-ann-test");
        let config = test_config(&home);
        let dest = format!("{}/myapp/config.toml", home.display());
        let result = resolve_destination(Some(&dest), "myapp", "config.toml", &config).unwrap();
        assert_eq!(result, PathBuf::from(&dest));
    }

    #[test]
    fn falls_back_to_default_dest_root_when_no_annotation() {
        let home = std::env::temp_dir().join("shine-ann-fallback");
        let config = test_config(&home);
        let result = resolve_destination(None, "starship", "starship.toml", &config).unwrap();
        // fallback is home/.config/starship/starship.toml
        assert!(result.ends_with("starship/starship.toml"));
    }

    #[test]
    fn fallback_keeps_nested_relative_path() {
        let home = std::env::temp_dir().join("shine-ann-nested");
        let config = test_config(&home);
        let result = resolve_destination(None, "vim", "colors/theme.vim", &config).unwrap();
        assert!(result.ends_with("vim/colors/theme.vim"));
    }

    #[test]
    fn rejects_path_with_parent_dir_component() {
        let home = std::env::temp_dir().join("shine-ann-sec");
        let config = test_config(&home);
        let dest = format!("{}/../etc/passwd", home.display());
        assert!(resolve_destination(Some(&dest), "x", "y", &config).is_err());
    }

    #[test]
    fn rejects_non_absolute_path_after_expansion() {
        let home = std::env::temp_dir().join("shine-ann-rel");
        let config = test_config(&home);
        assert!(resolve_destination(Some("relative/path"), "x", "y", &config).is_err());
    }
}