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
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(¬_a_repo).unwrap();
+ fs::write(not_a_repo.join("file.txt"), "content").unwrap();
+ let git = Git {
+ dir: expanded(¬_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");
}
}