assertions: cover everything that does not shell out to brew
upgrade and remove moved to trait defaults so the unimplemented stubs
live in one place instead of seven, and bin/coverage now excludes todo!
alongside unreachable!.

Assisted-by: Claude Opus 5 via Claude Code
change knxkykosyunyvzqztomnwyszzslmtpyp
commit 14211537cbfbfd5f54a61224d6795b53053c3136
author Alpha Chen <alpha@kejadlen.dev>
date
parent ttuypour
diff --git a/bin/coverage b/bin/coverage
index 089dfe6..c034cf4 100755
--- a/bin/coverage
+++ b/bin/coverage
@@ -34,7 +34,7 @@ main() {
         --ignore-not-existing \
         --keep-only 'frork-*/src/**' \
         --ignore 'frork-*/src/main.rs' \
-        --excl-line 'cov-excl-line|unreachable!' \
+        --excl-line 'cov-excl-line|unreachable!|todo!' \
         --excl-start 'cov-excl-start' \
         --excl-stop 'cov-excl-stop')
 
diff --git a/frork-cli/src/assertions.rs b/frork-cli/src/assertions.rs
index c4f983c..b432877 100644
--- a/frork-cli/src/assertions.rs
+++ b/frork-cli/src/assertions.rs
@@ -16,10 +16,21 @@ use crate::utils::Utils;
 pub trait AssertionType: std::fmt::Display {
     fn status(&self) -> Result<Status>;
     fn install(&self) -> Result<()>;
-    fn upgrade(&self) -> Result<()>;
+
+    // Neither is implemented for any assertion type yet. They default here so
+    // the stubs live in one place rather than being repeated per impl. Delete
+    // the markers below along with the todo! when implementing either.
+    // cov-excl-start
+    fn upgrade(&self) -> Result<()> {
+        todo!()
+    }
+
     // Not yet called — will be used when `frork remove` is implemented.
     #[allow(dead_code)]
-    fn remove(&self) -> Result<()>;
+    fn remove(&self) -> Result<()> {
+        todo!()
+    }
+    // cov-excl-stop
 }
 
 #[derive(Debug)]
@@ -140,22 +151,10 @@ impl AssertionType for Symlink {
             if link_target == Path::new(&self.source) {
                 Ok(Status::Ok)
             } else {
-                todo!(
-                    "{}",
-                    format!(
-                        "symlink {} {} points to wrong target",
-                        self.target, self.source
-                    )
-                );
+                todo!("{self} points to the wrong target");
             }
         } else {
-            todo!(
-                "{}",
-                format!(
-                    "symlink {} {} target exists but is not a symlink",
-                    self.target, self.source
-                )
-            );
+            todo!("{self} exists but is not a symlink");
         }
     }
 
@@ -166,14 +165,6 @@ impl AssertionType for Symlink {
         debug!("created: {}", self);
         Ok(())
     }
-
-    fn upgrade(&self) -> Result<()> {
-        todo!()
-    }
-
-    fn remove(&self) -> Result<()> {
-        todo!()
-    }
 }
 
 pub struct Directory {
@@ -199,10 +190,7 @@ impl AssertionType for Directory {
         if path.is_dir() {
             Ok(Status::Ok)
         } else if path.exists() {
-            todo!(
-                "{}",
-                format!("directory {} exists but is not a directory", self.path)
-            );
+            todo!("{self} exists but is not a directory");
         } else {
             Ok(Status::Missing)
         }
@@ -213,14 +201,6 @@ impl AssertionType for Directory {
         debug!("created: {}", self);
         Ok(())
     }
-
-    fn upgrade(&self) -> Result<()> {
-        todo!()
-    }
-
-    fn remove(&self) -> Result<()> {
-        todo!()
-    }
 }
 
 pub struct Git {
@@ -264,10 +244,14 @@ impl AssertionType for Git {
             return Ok(Status::Missing);
         }
 
-        let (remote_output, exit_code) = Utils::sh(
-            "git",
-            &["-C", &self.dir, "config", "--get", "remote.origin.url"],
-        )?;
+        let config_args = [
+            "-C",
+            self.dir.as_str(),
+            "config",
+            "--get",
+            "remote.origin.url",
+        ];
+        let (remote_output, exit_code) = Utils::sh("git", &config_args)?;
 
         if exit_code != 0 {
             return Ok(Status::ConflictUpgrade(Conflict {
@@ -299,14 +283,6 @@ impl AssertionType for Git {
         debug!("created: {}", self);
         Ok(())
     }
-
-    fn upgrade(&self) -> Result<()> {
-        todo!()
-    }
-
-    fn remove(&self) -> Result<()> {
-        todo!()
-    }
 }
 
 pub struct Brew;
@@ -355,14 +331,6 @@ impl AssertionType for Brew {
         debug!("installed: {}", self);
         Ok(())
     }
-
-    fn upgrade(&self) -> Result<()> {
-        todo!()
-    }
-
-    fn remove(&self) -> Result<()> {
-        todo!()
-    }
 }
 
 pub struct BrewBundle {
@@ -441,14 +409,6 @@ impl AssertionType for BrewBundle {
         debug!("installed: {}", self);
         Ok(())
     }
-
-    fn upgrade(&self) -> Result<()> {
-        todo!()
-    }
-
-    fn remove(&self) -> Result<()> {
-        todo!()
-    }
 }
 
 pub struct Debug {
@@ -497,14 +457,6 @@ impl AssertionType for Debug {
             .map_err(|e| miette!("Debug install function failed: {e}"))?;
         Ok(())
     }
-
-    fn upgrade(&self) -> Result<()> {
-        todo!()
-    }
-
-    fn remove(&self) -> Result<()> {
-        todo!()
-    }
 }
 
 // --- Lua custom assertion types ---
@@ -592,12 +544,503 @@ impl AssertionType for LuaAssertion {
         debug!("installed: {}", self);
         Ok(())
     }
+}
 
-    fn upgrade(&self) -> Result<()> {
-        todo!()
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use tempfile::TempDir;
+
+    fn multi(lua: &Lua, script: &str) -> LuaMultiValue {
+        lua.load(script).eval::<LuaMultiValue>().unwrap()
     }
 
-    fn remove(&self) -> Result<()> {
-        todo!()
+    fn expanded(path: &Path) -> ExpandedPath {
+        ExpandedPath::try_from(path.to_str().unwrap()).unwrap()
+    }
+
+    // --- Status / Conflict ---
+
+    fn status_from(lua: &Lua, script: &str) -> LuaResult<Status> {
+        Status::from_lua_multi(multi(lua, script), lua)
+    }
+
+    #[test]
+    fn test_status_from_lua_single_strings() {
+        let lua = Lua::new();
+
+        assert!(matches!(
+            status_from(&lua, r#"return "ok""#),
+            Ok(Status::Ok)
+        ));
+        assert!(matches!(
+            status_from(&lua, r#"return "missing""#),
+            Ok(Status::Missing)
+        ));
+
+        let err = status_from(&lua, r#"return "bogus""#).unwrap_err();
+        assert!(err.to_string().contains("Invalid status string: 'bogus'"));
+    }
+
+    #[test]
+    fn test_status_from_lua_conflict_upgrade() {
+        let lua = Lua::new();
+
+        let status = status_from(
+            &lua,
+            r#"return "conflict-upgrade", {expected = "a", actual = "b"}"#,
+        )
+        .unwrap();
+        let Status::ConflictUpgrade(conflict) = status else {
+            panic!("expected a conflict-upgrade status"); // cov-excl-line
+        };
+        assert_eq!(conflict.expected, "a");
+        assert_eq!(conflict.actual, "b");
+    }
+
+    #[test]
+    fn test_status_from_lua_rejects_other_strings_with_conflict() {
+        let lua = Lua::new();
+
+        let err = status_from(&lua, r#"return "ok", {expected = "a", actual = "b"}"#).unwrap_err();
+        assert!(
+            err.to_string()
+                .contains("only supported for conflict-upgrade")
+        );
+    }
+
+    #[test]
+    fn test_status_from_lua_rejects_non_string() {
+        let lua = Lua::new();
+
+        let err = status_from(&lua, r#"return true"#).unwrap_err();
+        assert!(
+            err.to_string()
+                .contains("Expected single string or conflict-upgrade with table")
+        );
+    }
+
+    // --- TypedFactory ---
+
+    #[test]
+    fn test_typed_factory_creates_assertion() {
+        let lua = Lua::new();
+        let factory: TypedFactory<Directory> = TypedFactory::default();
+
+        let assertion = factory
+            .create(&lua, multi(&lua, r#"return "/tmp""#))
+            .unwrap();
+        assert_eq!(assertion.to_string(), "directory /tmp");
+    }
+
+    #[test]
+    fn test_typed_factory_reports_conversion_failure() {
+        let lua = Lua::new();
+        let factory: TypedFactory<Directory> = TypedFactory::new();
+
+        // Box<dyn AssertionType> is not Debug, so unwrap_err is unavailable.
+        let Err(error) = factory.create(&lua, multi(&lua, r#"return true"#)) else {
+            panic!("expected a conversion failure"); // cov-excl-line
+        };
+        assert!(
+            error
+                .to_string()
+                .contains("Failed to create assertion type")
+        );
+    }
+
+    // --- Symlink ---
+
+    #[test]
+    fn test_symlink_lifecycle() {
+        let dir = TempDir::new().unwrap();
+        let source = dir.path().join("source.txt");
+        fs::write(&source, "content").unwrap();
+        let target = dir.path().join("link");
+
+        let symlink = Symlink {
+            target: expanded(&target),
+            source: expanded(&source),
+        };
+
+        assert_eq!(
+            symlink.to_string(),
+            format!("symlink {} {}", target.display(), source.display())
+        );
+        assert!(matches!(symlink.status().unwrap(), Status::Missing));
+
+        symlink.install().unwrap();
+        assert!(matches!(symlink.status().unwrap(), Status::Ok));
+    }
+
+    #[test]
+    fn test_symlink_install_failure() {
+        let dir = TempDir::new().unwrap();
+        let symlink = Symlink {
+            target: expanded(&dir.path().join("missing-parent/link")),
+            source: expanded(&dir.path().join("source.txt")),
+        };
+
+        let error = symlink.install().unwrap_err();
+        assert!(error.to_string().contains("Failed to create symlink"));
+    }
+
+    #[test]
+    fn test_symlink_from_lua() {
+        let lua = Lua::new();
+        let symlink =
+            Symlink::from_lua_multi(multi(&lua, r#"return "/tmp/link", "/tmp/source""#), &lua)
+                .unwrap();
+
+        assert_eq!(symlink.target.as_str(), "/tmp/link");
+        assert_eq!(symlink.source.as_str(), "/tmp/source");
+    }
+
+    // --- Directory ---
+
+    #[test]
+    fn test_directory_lifecycle() {
+        let dir = TempDir::new().unwrap();
+        let path = dir.path().join("nested/deep");
+        let directory = Directory {
+            path: expanded(&path),
+        };
+
+        assert_eq!(
+            directory.to_string(),
+            format!("directory {}", path.display())
+        );
+        assert!(matches!(directory.status().unwrap(), Status::Missing));
+
+        directory.install().unwrap();
+        assert!(matches!(directory.status().unwrap(), Status::Ok));
+    }
+
+    #[test]
+    fn test_directory_install_failure() {
+        let dir = TempDir::new().unwrap();
+        let blocker = dir.path().join("blocker");
+        fs::write(&blocker, "not a directory").unwrap();
+
+        let directory = Directory {
+            path: expanded(&blocker.join("child")),
+        };
+        assert!(directory.install().is_err());
+    }
+
+    #[test]
+    fn test_directory_from_lua() {
+        let lua = Lua::new();
+        let directory = Directory::from_lua_multi(multi(&lua, r#"return "/tmp""#), &lua).unwrap();
+
+        assert_eq!(directory.path.as_str(), "/tmp");
+    }
+
+    // --- Git ---
+
+    fn bare_origin(dir: &Path) -> String {
+        let origin = dir.join("origin.git");
+        let (_out, code) = Utils::sh("git", &["init", "--bare", origin.to_str().unwrap()]).unwrap();
+        assert_eq!(code, 0);
+        origin.to_str().unwrap().to_string()
+    }
+
+    #[test]
+    fn test_git_lifecycle() {
+        let dir = TempDir::new().unwrap();
+        let remote_url = bare_origin(dir.path());
+        let clone = dir.path().join("clone");
+
+        let git = Git {
+            dir: expanded(&clone),
+            remote_url: remote_url.clone(),
+        };
+
+        assert_eq!(
+            git.to_string(),
+            format!("git {} {}", clone.display(), remote_url)
+        );
+        assert!(matches!(git.status().unwrap(), Status::Missing));
+
+        git.install().unwrap();
+        assert!(matches!(git.status().unwrap(), Status::Ok));
+    }
+
+    #[test]
+    fn test_git_status_conflicts() {
+        let dir = TempDir::new().unwrap();
+        let remote_url = bare_origin(dir.path());
+
+        // A file where a directory is expected.
+        let file = dir.path().join("a-file");
+        fs::write(&file, "content").unwrap();
+        let git = Git {
+            dir: expanded(&file),
+            remote_url: remote_url.clone(),
+        };
+        let Status::ConflictUpgrade(conflict) = git.status().unwrap() else {
+            panic!("expected a conflict for a non-directory path"); // cov-excl-line
+        };
+        assert_eq!(conflict.expected, "directory");
+        assert_eq!(conflict.actual, "file");
+
+        // An empty directory counts as missing rather than conflicting.
+        let empty = dir.path().join("empty");
+        fs::create_dir_all(&empty).unwrap();
+        let git = Git {
+            dir: expanded(&empty),
+            remote_url: remote_url.clone(),
+        };
+        assert!(matches!(git.status().unwrap(), Status::Missing));
+
+        // A non-empty directory that is not a repo has no remote to read.
+        let not_a_repo = dir.path().join("not-a-repo");
+        fs::create_dir_all(&not_a_repo).unwrap();
+        fs::write(not_a_repo.join("file.txt"), "content").unwrap();
+        let git = Git {
+            dir: expanded(&not_a_repo),
+            remote_url: remote_url.clone(),
+        };
+        let Status::ConflictUpgrade(conflict) = git.status().unwrap() else {
+            panic!("expected a conflict when the remote cannot be read"); // cov-excl-line
+        };
+        assert_eq!(conflict.actual, "failed to get remote url");
+    }
+
+    #[test]
+    fn test_git_status_wrong_remote() {
+        let dir = TempDir::new().unwrap();
+        let remote_url = bare_origin(dir.path());
+        let clone = dir.path().join("clone");
+
+        Git {
+            dir: expanded(&clone),
+            remote_url: remote_url.clone(),
+        }
+        .install()
+        .unwrap();
+
+        let git = Git {
+            dir: expanded(&clone),
+            remote_url: "https://example.com/other.git".to_string(),
+        };
+        let Status::ConflictUpgrade(conflict) = git.status().unwrap() else {
+            panic!("expected a conflict for a mismatched remote"); // cov-excl-line
+        };
+        assert_eq!(conflict.expected, "https://example.com/other.git");
+        assert!(conflict.actual.contains(&remote_url));
+    }
+
+    #[test]
+    fn test_git_install_failure() {
+        let dir = TempDir::new().unwrap();
+        let git = Git {
+            dir: expanded(&dir.path().join("clone")),
+            remote_url: dir.path().join("does-not-exist.git").display().to_string(),
+        };
+
+        let error = git.install().unwrap_err();
+        assert!(error.to_string().contains("Failed to clone git repository"));
+    }
+
+    #[test]
+    fn test_git_from_lua() {
+        let lua = Lua::new();
+        let git = Git::from_lua_multi(
+            multi(&lua, r#"return "/tmp/repo", "https://example.com/r.git""#),
+            &lua,
+        )
+        .unwrap();
+
+        assert_eq!(git.dir.as_str(), "/tmp/repo");
+        assert_eq!(git.remote_url, "https://example.com/r.git");
+    }
+
+    // --- Brew ---
+
+    #[test]
+    fn test_brew_display_and_from_lua() {
+        let lua = Lua::new();
+        let brew = Brew::from_lua_multi(LuaMultiValue::new(), &lua).unwrap();
+
+        assert_eq!(brew.to_string(), "brew");
+    }
+
+    #[test]
+    fn test_brew_bundle_display_and_from_lua() {
+        let lua = Lua::new();
+        let bundle =
+            BrewBundle::from_lua_multi(multi(&lua, r#"return "/tmp/Brewfile""#), &lua).unwrap();
+
+        assert_eq!(bundle.to_string(), "brew-bundle /tmp/Brewfile");
+        assert_eq!(bundle.brewfile.as_str(), "/tmp/Brewfile");
+    }
+
+    // --- Debug ---
+
+    #[test]
+    fn test_debug_defaults_to_ok() {
+        let debug = Debug {
+            status_fn: None,
+            install_fn: None,
+        };
+
+        assert_eq!(debug.to_string(), "debug");
+        assert!(matches!(debug.status().unwrap(), Status::Ok));
+
+        let error = debug.install().unwrap_err();
+        assert!(
+            error
+                .to_string()
+                .contains("Install not implemented for debug assertion")
+        );
+    }
+
+    #[test]
+    fn test_debug_runs_lua_functions() {
+        let lua = Lua::new();
+        let debug = Debug::from_lua_multi(
+            multi(
+                &lua,
+                r#"return {status = function() return "missing" end, install = function() end}"#,
+            ),
+            &lua,
+        )
+        .unwrap();
+
+        assert!(matches!(debug.status().unwrap(), Status::Missing));
+        debug.install().unwrap();
+    }
+
+    #[test]
+    fn test_debug_propagates_lua_failures() {
+        let lua = Lua::new();
+        let debug = Debug::from_lua_multi(
+            multi(
+                &lua,
+                r#"return {
+                    status = function() error("status boom") end,
+                    install = function() error("install boom") end,
+                }"#,
+            ),
+            &lua,
+        )
+        .unwrap();
+
+        let error = debug.status().unwrap_err();
+        assert!(error.to_string().contains("Debug status function failed"));
+
+        let error = debug.install().unwrap_err();
+        assert!(error.to_string().contains("Debug install function failed"));
+    }
+
+    // --- Lua assertion types ---
+
+    fn lua_assertion_type(lua: &Lua, script: &str) -> LuaResult<LuaAssertionType> {
+        let value = lua.load(script).eval::<LuaValue>().unwrap();
+        LuaAssertionType::from_lua(value, lua)
+    }
+
+    #[test]
+    fn test_lua_assertion_type_requires_status_and_install() {
+        let lua = Lua::new();
+
+        let assertion_type = lua_assertion_type(
+            &lua,
+            r#"return {status = function() return "ok" end, install = function() end}"#,
+        )
+        .unwrap();
+        assert!(assertion_type.display_fn.is_none());
+
+        assert!(lua_assertion_type(&lua, r#"return {install = function() end}"#).is_err());
+    }
+
+    #[test]
+    fn test_lua_assertion_runs_status_and_install() {
+        let lua = Lua::new();
+        let assertion_type = lua_assertion_type(
+            &lua,
+            r#"return {
+                status = function(a) if a == "yes" then return "ok" else return "missing" end end,
+                install = function() end,
+            }"#,
+        )
+        .unwrap();
+
+        let assertion = LuaAssertion::new(
+            "custom",
+            multi(&lua, r#"return "yes""#),
+            assertion_type.clone(),
+        );
+        assert!(matches!(assertion.status().unwrap(), Status::Ok));
+        assertion.install().unwrap();
+
+        let assertion = LuaAssertion::new("custom", multi(&lua, r#"return "no""#), assertion_type);
+        assert!(matches!(assertion.status().unwrap(), Status::Missing));
+    }
+
+    #[test]
+    fn test_lua_assertion_propagates_failures() {
+        let lua = Lua::new();
+        let assertion_type = lua_assertion_type(
+            &lua,
+            r#"return {
+                status = function() error("status boom") end,
+                install = function() error("install boom") end,
+            }"#,
+        )
+        .unwrap();
+
+        let assertion = LuaAssertion::new("custom", LuaMultiValue::new(), assertion_type);
+        assert!(assertion.status().is_err());
+        assert!(assertion.install().is_err());
+    }
+
+    #[test]
+    fn test_lua_assertion_display_without_display_fn() {
+        let lua = Lua::new();
+        let assertion_type = lua_assertion_type(
+            &lua,
+            r#"return {status = function() return "ok" end, install = function() end}"#,
+        )
+        .unwrap();
+
+        let assertion =
+            LuaAssertion::new("custom", multi(&lua, r#"return "a", "b""#), assertion_type);
+        assert_eq!(assertion.to_string(), "custom a b");
+    }
+
+    #[test]
+    fn test_lua_assertion_display_fn_replaces_default() {
+        let lua = Lua::new();
+        let assertion_type = lua_assertion_type(
+            &lua,
+            r#"return {
+                display = function(a) return "rendered " .. a end,
+                status = function() return "ok" end,
+                install = function() end,
+            }"#,
+        )
+        .unwrap();
+
+        let assertion = LuaAssertion::new("custom", multi(&lua, r#"return "arg""#), assertion_type);
+        assert_eq!(assertion.to_string(), "rendered arg");
+    }
+
+    #[test]
+    fn test_lua_assertion_display_fn_failure_falls_back() {
+        let lua = Lua::new();
+        let assertion_type = lua_assertion_type(
+            &lua,
+            r#"return {
+                display = function() error("display boom") end,
+                status = function() return "ok" end,
+                install = function() end,
+            }"#,
+        )
+        .unwrap();
+
+        let assertion = LuaAssertion::new("custom", multi(&lua, r#"return "arg""#), assertion_type);
+        assert_eq!(assertion.to_string(), "custom arg");
     }
 }