putter 0.1.0

A tool to put files in the right place
Documentation
use assert_fs::{
    fixture::ChildPath,
    prelude::{FileWriteStr, PathAssert, PathChild},
    TempDir,
};
use predicates::prelude::predicate;
use std::path::PathBuf;
use std::process;
use std::str::FromStr as _;

fn putter_cmd() -> process::Command {
    let exe = PathBuf::from_str(env!("CARGO_BIN_EXE_putter")).expect("putter path");
    process::Command::new(exe)
}

mod bad_manifest_prints_error {
    use serde_json::json;

    use super::*;

    struct Scenario {
        source_dir: TempDir,
        manifest_file: ChildPath,
        state_file: ChildPath,
    }

    impl Scenario {
        // Creates a test scenario with a manifest file that is missing the
        // version field.
        fn init() -> anyhow::Result<Scenario> {
            let source_dir = TempDir::new()?;

            let manifest_file = source_dir.child("manifest.json");
            manifest_file.write_str(
                &json!({
                  "files": []
                })
                .to_string(),
            )?;

            let state_file = source_dir.child("state.json");

            Ok(Scenario {
                source_dir,
                manifest_file,
                state_file,
            })
        }

        fn close(self) -> anyhow::Result<()> {
            self.source_dir.close()?;
            Ok(())
        }
    }

    /// Asserts that the command failed with the expected error output.
    macro_rules! assert_output {
        ($scenario:expr, $output:expr) => {
            let manifest = $scenario.manifest_file.path().display();
            let expected_stderr = format!(
                r#"Error: error loading manifest from {manifest}

Caused by:
    missing field `version` at line 1 column 12
"#
            );

            assert!(!$output.status.success());
            assert_eq!(&String::from_utf8($output.stdout)?, "");

            // Note, we only assert the stderr prefix since a stacktrace may
            // also be added.
            assert!(String::from_utf8($output.stderr)?.starts_with(&expected_stderr));
        };
    }

    #[test]
    fn check() -> anyhow::Result<()> {
        testing_logger::setup();

        let scenario = Scenario::init()?;

        let output = putter_cmd()
            .arg("check")
            .arg("--state-file")
            .arg(scenario.state_file.path())
            .arg(scenario.manifest_file.path())
            .output()?;

        assert_output!(scenario, output);

        scenario.close()
    }

    #[test]
    fn apply_dry_run() -> anyhow::Result<()> {
        testing_logger::setup();

        let scenario = Scenario::init()?;

        let output = putter_cmd()
            .arg("apply")
            .arg("--state-file")
            .arg(scenario.state_file.path())
            .arg("--dry-run")
            .arg(scenario.manifest_file.path())
            .output()?;

        assert_output!(scenario, output);

        scenario.close()
    }

    #[test]
    fn apply() -> anyhow::Result<()> {
        testing_logger::setup();

        let scenario = Scenario::init()?;

        let output = putter_cmd()
            .arg("apply")
            .arg("--state-file")
            .arg(scenario.state_file.path())
            .arg(scenario.manifest_file.path())
            .output()?;

        assert_output!(scenario, output);

        scenario.close()
    }
}

mod symlink_no_collision_empty_state {
    use serde_json::json;

    use super::*;

    struct Scenario {
        source_dir: TempDir,
        target_dir: TempDir,
        source_file: ChildPath,
        target_file: ChildPath,
        manifest_file: ChildPath,
        state_file: ChildPath,
    }

    impl Scenario {
        fn init() -> anyhow::Result<Scenario> {
            let source_dir = TempDir::new()?;
            let target_dir = TempDir::new()?;

            let source_file = source_dir.child("s_file");
            let target_file = target_dir.child("t_file");

            source_file.write_str("content")?;

            let manifest_file = source_dir.child("manifest.json");
            manifest_file.write_str(
                &json!({
                  "version": "1",
                  "files": [
                      {
                          "source": source_file.to_path_buf(),
                          "target": target_file.to_path_buf()
                      }
                  ]
                })
                .to_string(),
            )?;

            let state_file = source_dir.child("state.json");

            Ok(Scenario {
                source_dir,
                target_dir,
                source_file,
                target_file,
                manifest_file,
                state_file,
            })
        }

        fn close(self) -> anyhow::Result<()> {
            // The source file should still exist.
            self.source_file.assert(predicate::path::exists());

            self.source_dir.close()?;
            self.target_dir.close()?;
            Ok(())
        }
    }

    #[test]
    fn check() -> anyhow::Result<()> {
        testing_logger::setup();

        let scenario = Scenario::init()?;

        let output = putter_cmd()
            .arg("check")
            .arg("--state-file")
            .arg(scenario.state_file.path())
            .arg(scenario.manifest_file.path())
            .output()?;

        assert!(output.status.success());
        assert_eq!(&String::from_utf8(output.stdout)?, "");
        assert_eq!(&String::from_utf8(output.stderr)?, "");

        scenario.target_file.assert(predicate::path::missing());

        scenario.close()
    }

    #[test]
    fn apply_dry_run() -> anyhow::Result<()> {
        testing_logger::setup();

        let scenario = Scenario::init()?;

        let output = putter_cmd()
            .arg("apply")
            .arg("--state-file")
            .arg(scenario.state_file.path())
            .arg("--dry-run")
            .arg(scenario.manifest_file.path())
            .output()?;

        assert!(output.status.success());
        assert_eq!(&String::from_utf8(output.stdout)?, "");
        assert_eq!(&String::from_utf8(output.stderr)?, "");

        scenario.target_file.assert(predicate::path::missing());

        scenario.close()
    }

    #[test]
    fn apply() -> anyhow::Result<()> {
        testing_logger::setup();

        let scenario = Scenario::init()?;

        let output = putter_cmd()
            .arg("apply")
            .arg("--state-file")
            .arg(scenario.state_file.path())
            .arg(scenario.manifest_file.path())
            .output()?;

        assert!(output.status.success());
        assert_eq!(&String::from_utf8(output.stdout)?, "");
        assert_eq!(&String::from_utf8(output.stderr)?, "");

        scenario
            .target_file
            .assert(predicate::path::is_symlink())
            .assert("content");

        scenario.close()
    }
}