diff --git a/local-identity/src/local_identity/cli.py b/local-identity/src/local_identity/cli.py index 8198233..33399ef 100644 --- a/local-identity/src/local_identity/cli.py +++ b/local-identity/src/local_identity/cli.py @@ -2,8 +2,10 @@ local-identity CLI — entry point. Commands: - init [--force] [--email EMAIL] Derive primary user from Linux identity, - generate test users, write store. + init [--force] [--username U] [--fullname N] [--email E] + Derive primary user, generate test users, + write store. All three identity fields are + resolved flag > config > system derivation. list List all users in the store. show Display a user's YAML record. @@ -19,6 +21,20 @@ from .user import UserRecord, make_test_user from . import store +def _resolve_init_params(args: argparse.Namespace, config: dict) -> tuple[str, str, str]: + """ + Resolve (username, fullname, email) for init from three sources in order: + 1. CLI flags (--username, --fullname, --email) + 2. Persisted config (~/.local-identity/config.yaml) + 3. System derivation ($USER / /etc/passwd GECOS) — username + fullname only + Email has no system default; missing email falls through to prompt in cmd_init. + """ + username: str = args.username or config.get("username") or current_username() + fullname: str = args.fullname or config.get("fullname") or get_gecos_fullname(username) + email: str = args.email or config.get("email") or "" + return username, fullname, email + + def cmd_init(args: argparse.Namespace) -> None: if store.store_exists() and not args.force: print( @@ -28,12 +44,9 @@ def cmd_init(args: argparse.Namespace) -> None: ) sys.exit(1) - username = current_username() - fullname = get_gecos_fullname(username) - - # Email resolution: flag > existing config > interactive prompt config = store.read_config() if store.store_exists() else {} - email: str = args.email or config.get("email", "") + username, fullname, email = _resolve_init_params(args, config) + if not email: try: email = input(f"Email address for {username}: ").strip() @@ -44,7 +57,7 @@ def cmd_init(args: argparse.Namespace) -> None: sys.exit(1) store.init_dirs() - config["email"] = email + config.update({"username": username, "fullname": fullname, "email": email}) store.write_config(config) primary = UserRecord(username=username, fullname=fullname, email=email) @@ -102,9 +115,17 @@ def main() -> None: "--force", action="store_true", help="Reinitialise even if the store already exists", ) + p_init.add_argument( + "--username", + help="Bootstrap username (default: $USER / $LOGNAME)", + ) + p_init.add_argument( + "--fullname", + help="Full display name (default: /etc/passwd GECOS field)", + ) p_init.add_argument( "--email", - help="Email address (skips interactive prompt)", + help="Email address (default: interactive prompt)", ) p_init.set_defaults(func=cmd_init) diff --git a/local-identity/tests/test_cli.py b/local-identity/tests/test_cli.py new file mode 100644 index 0000000..1c3f885 --- /dev/null +++ b/local-identity/tests/test_cli.py @@ -0,0 +1,151 @@ +""" +Tests for CLI init parameter resolution and command behaviour. + +cmd_init resolution order: flag > config > system derivation. +All three identity fields (username, fullname, email) follow this pattern. +""" + +import argparse +from unittest.mock import patch + +import pytest + +from local_identity.cli import _resolve_init_params, cmd_init +from local_identity.store import init_dirs, list_users, read_config, read_user + + +def _args(username=None, fullname=None, email=None, force=False): + """Build a minimal Namespace for _resolve_init_params / cmd_init.""" + ns = argparse.Namespace() + ns.username = username + ns.fullname = fullname + ns.email = email + ns.force = force + ns.func = cmd_init + return ns + + +# ------------------------------------------------------------------ # +# _resolve_init_params # +# ------------------------------------------------------------------ # + +class TestResolveInitParams: + def test_flags_take_priority_over_config_and_system(self): + config = {"username": "from_config", "fullname": "Config Name", "email": "config@x.com"} + with ( + patch("local_identity.cli.current_username", return_value="system_user"), + patch("local_identity.cli.get_gecos_fullname", return_value="System Name"), + ): + u, f, e = _resolve_init_params( + _args(username="tegwick", fullname="Bernd Worsch", email="bernd@example.com"), + config, + ) + assert u == "tegwick" + assert f == "Bernd Worsch" + assert e == "bernd@example.com" + + def test_config_takes_priority_over_system(self): + config = {"username": "tegwick", "fullname": "Bernd Worsch", "email": "bernd@example.com"} + with ( + patch("local_identity.cli.current_username", return_value="worsch"), + patch("local_identity.cli.get_gecos_fullname", return_value="worsch"), + ): + u, f, e = _resolve_init_params(_args(), config) + assert u == "tegwick" + assert f == "Bernd Worsch" + assert e == "bernd@example.com" + + def test_system_derivation_when_no_flags_or_config(self): + config = {} + with ( + patch("local_identity.cli.current_username", return_value="worsch"), + patch("local_identity.cli.get_gecos_fullname", return_value="Bernd Worsch"), + ): + u, f, e = _resolve_init_params(_args(), config) + assert u == "worsch" + assert f == "Bernd Worsch" + assert e == "" # no default; caller must prompt + + def test_flag_username_overrides_config_username(self): + config = {"username": "wrong", "fullname": "X", "email": "x@x.com"} + with patch("local_identity.cli.current_username", return_value="system"): + u, _, _ = _resolve_init_params(_args(username="tegwick"), config) + assert u == "tegwick" + + def test_flag_fullname_overrides_config_fullname(self): + config = {"username": "u", "fullname": "Wrong Name", "email": "x@x.com"} + with patch("local_identity.cli.get_gecos_fullname", return_value="Gecos Name"): + _, f, _ = _resolve_init_params(_args(fullname="Bernd Worsch"), config) + assert f == "Bernd Worsch" + + def test_gecos_used_when_no_fullname_flag_or_config(self): + config = {"username": "tegwick", "email": "x@x.com"} + with patch("local_identity.cli.get_gecos_fullname", return_value="Bernd Worsch"): + _, f, _ = _resolve_init_params(_args(), config) + assert f == "Bernd Worsch" + + def test_empty_email_when_absent_everywhere(self): + config = {"username": "u", "fullname": "N"} + _, _, e = _resolve_init_params(_args(), config) + assert e == "" + + +# ------------------------------------------------------------------ # +# cmd_init integration (uses tmp_store fixture) # +# ------------------------------------------------------------------ # + +class TestCmdInit: + def test_init_with_all_flags(self, tmp_store): + with ( + patch("local_identity.cli.current_username", return_value="worsch"), + patch("local_identity.cli.get_gecos_fullname", return_value="worsch"), + ): + cmd_init(_args(username="tegwick", fullname="Bernd Worsch", email="bernd@example.com")) + + primary = read_user("tegwick") + assert primary.username == "tegwick" + assert primary.fullname == "Bernd Worsch" + assert primary.email == "bernd@example.com" + + def test_test_users_derived_from_override_username(self, tmp_store): + with patch("local_identity.cli.current_username", return_value="worsch"): + cmd_init(_args(username="tegwick", fullname="Bernd Worsch", email="bernd@example.com")) + + users = {u.username for u in list_users()} + assert users == {"tegwick", "tegwick1", "tegwick2"} + + def test_test_user_email_uses_override_email(self, tmp_store): + with patch("local_identity.cli.current_username", return_value="worsch"): + cmd_init(_args(username="tegwick", fullname="Bernd Worsch", email="bernd@example.com")) + + assert read_user("tegwick1").email == "bernd+test1@example.com" + assert read_user("tegwick2").email == "bernd+test2@example.com" + + def test_overrides_persisted_to_config(self, tmp_store): + with patch("local_identity.cli.current_username", return_value="worsch"): + cmd_init(_args(username="tegwick", fullname="Bernd Worsch", email="bernd@example.com")) + + cfg = read_config() + assert cfg["username"] == "tegwick" + assert cfg["fullname"] == "Bernd Worsch" + assert cfg["email"] == "bernd@example.com" + + def test_force_reinit_reads_config_without_flags(self, tmp_store): + with patch("local_identity.cli.current_username", return_value="worsch"): + cmd_init(_args(username="tegwick", fullname="Bernd Worsch", email="bernd@example.com")) + # Second init with --force and no flags — should reuse config + cmd_init(_args(force=True)) + + primary = read_user("tegwick") + assert primary.username == "tegwick" + assert primary.fullname == "Bernd Worsch" + + def test_fails_if_store_exists_without_force(self, tmp_store): + with ( + patch("local_identity.cli.current_username", return_value="worsch"), + pytest.raises(SystemExit) as exc_info, + ): + cmd_init(_args(email="a@b.com")) + cmd_init(_args(email="a@b.com")) # second call should fail + + assert exc_info.value.code == 1