pub mod agents_md;
pub mod connect;
pub mod credentials;
pub mod doctor;
pub mod exec;
pub mod http;
pub mod import;
pub mod login;
pub mod service;
pub mod term;
pub mod ui;
use clap::{Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
#[error("--api-key was provided but empty")]
pub(super) struct EmptyHttpCredential;
#[must_use = "iterate over the non-empty CSV values"]
pub(super) fn split_csv(value: &str) -> impl Iterator<Item = &str> {
value
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
}
#[must_use = "use the parsed label values"]
pub(super) fn borrowed_labels(value: Option<&str>) -> Option<Vec<&str>> {
value.map(|value| split_csv(value).collect())
}
#[must_use = "use the resolved HTTP credential"]
pub(super) fn resolve_http_credential(
explicit: Option<&str>,
stored: impl FnOnce() -> Option<String>,
) -> Result<Option<String>, EmptyHttpCredential> {
match explicit {
Some(value) => {
let value = value.trim();
if value.is_empty() {
Err(EmptyHttpCredential)
} else {
Ok(Some(value.to_owned()))
}
}
None => Ok(stored()
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())),
}
}
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
pub enum BackendKind {
Sql,
Http,
}
#[derive(Parser)]
#[command(
name = "lific",
version,
about = "Local-first, lightweight issue tracker"
)]
pub struct Cli {
#[arg(long, global = true)]
pub config: Option<PathBuf>,
#[arg(long, global = true)]
pub db: Option<PathBuf>,
#[arg(long, global = true)]
pub json: bool,
#[arg(long, global = true, value_enum, default_value_t = BackendKind::Sql)]
pub backend: BackendKind,
#[arg(long, global = true, env = "LIFIC_URL")]
pub url: Option<String>,
#[arg(long = "api-key", global = true, env = "LIFIC_API_KEY")]
pub api_key: Option<String>,
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
pub enum Command {
Start {
#[arg(short, long)]
port: Option<u16>,
#[arg(long)]
host: Option<String>,
},
Mcp,
Login {
#[arg(long)]
url: Option<String>,
#[arg(long = "non-interactive")]
non_interactive: bool,
#[arg(long)]
complete: Option<String>,
#[arg(long)]
label: Option<String>,
#[arg(long = "no-store")]
no_store: bool,
},
Logout {
#[arg(long)]
url: Option<String>,
},
Doctor {
#[arg(long, env = "LIFIC_API_KEY")]
key: Option<String>,
},
Init {
#[arg(long = "no-service")]
no_service: bool,
#[arg(long)]
here: bool,
},
Service {
#[command(subcommand)]
action: ServiceAction,
},
Dump {
#[arg(long)]
out: Option<PathBuf>,
},
Restore {
archive: PathBuf,
#[arg(long)]
force: bool,
},
Connect {
#[arg(long = "client")]
clients: Vec<String>,
#[arg(long, default_value = "global")]
scope: String,
#[arg(long)]
stdio: bool,
#[arg(long)]
oauth: bool,
#[arg(long)]
url: Option<String>,
#[arg(long)]
key: Option<String>,
#[arg(long)]
user: Option<String>,
#[arg(long)]
yes: bool,
#[arg(long = "dry-run")]
dry_run: bool,
#[arg(long = "skip-agents")]
skip_agents: bool,
},
AgentsMd {
#[arg(long)]
path: Option<PathBuf>,
#[arg(long)]
project: Option<String>,
},
Completion {
shell: clap_complete::Shell,
},
Instance {
#[command(subcommand)]
action: InstanceAction,
},
Key {
#[command(subcommand)]
action: KeyAction,
},
User {
#[command(subcommand)]
action: UserAction,
},
Member {
#[command(subcommand)]
action: MemberAction,
},
Issue {
#[command(subcommand)]
action: IssueAction,
},
Project {
#[command(subcommand)]
action: ProjectAction,
},
Page {
#[command(subcommand)]
action: PageAction,
},
Export {
#[command(subcommand)]
action: ExportAction,
},
Search {
query: String,
#[arg(short, long)]
project: Option<String>,
#[arg(short, long)]
limit: Option<i64>,
},
Comment {
#[command(subcommand)]
action: CommentAction,
},
Module {
#[command(subcommand)]
action: ModuleAction,
},
Label {
#[command(subcommand)]
action: LabelAction,
},
Folder {
#[command(subcommand)]
action: FolderAction,
},
Import {
#[command(subcommand)]
action: ImportAction,
},
}
#[derive(Subcommand)]
pub enum ServiceAction {
Install,
Uninstall,
Status,
Stop,
Restart,
}
#[derive(Subcommand)]
pub enum ImportAction {
Github {
#[arg(long)]
repo: String,
#[arg(long)]
project: String,
#[arg(long, default_value = "all")]
state: String,
#[arg(long, env = "GITHUB_TOKEN")]
token: Option<String>,
#[arg(long = "map-open", default_value = "backlog")]
map_open: String,
#[arg(long = "map-closed", default_value = "done")]
map_closed: String,
#[arg(long)]
user: Option<String>,
#[arg(long = "dry-run")]
dry_run: bool,
},
Linear {
#[arg(long)]
team: String,
#[arg(long)]
project: String,
#[arg(long, env = "LINEAR_API_KEY")]
token: Option<String>,
#[arg(long)]
user: Option<String>,
#[arg(long = "dry-run")]
dry_run: bool,
},
Jira {
#[arg(long)]
site: String,
#[arg(long = "jira-project")]
jira_project: String,
#[arg(long)]
project: String,
#[arg(long, env = "JIRA_EMAIL")]
email: Option<String>,
#[arg(long, env = "JIRA_API_TOKEN")]
token: Option<String>,
#[arg(long)]
user: Option<String>,
#[arg(long = "dry-run")]
dry_run: bool,
},
}
#[derive(Subcommand)]
pub enum IssueAction {
List {
#[arg(short, long)]
project: String,
#[arg(short, long)]
status: Option<String>,
#[arg(long)]
priority: Option<String>,
#[arg(short, long)]
module: Option<String>,
#[arg(short, long)]
label: Option<String>,
#[arg(short, long)]
workable: bool,
#[arg(long)]
limit: Option<i64>,
},
Get {
identifier: String,
},
Create {
#[arg(short, long)]
project: String,
#[arg(short, long)]
title: String,
#[arg(short, long, default_value = "")]
description: String,
#[arg(short, long, default_value = "backlog")]
status: String,
#[arg(long, default_value = "none")]
priority: String,
#[arg(short, long)]
module: Option<String>,
#[arg(short, long)]
labels: Option<String>,
},
Update {
identifier: String,
#[arg(short, long)]
title: Option<String>,
#[arg(short, long)]
description: Option<String>,
#[arg(short, long)]
status: Option<String>,
#[arg(long)]
priority: Option<String>,
#[arg(short, long)]
module: Option<String>,
#[arg(short, long)]
labels: Option<String>,
},
}
#[derive(Subcommand)]
pub enum ProjectAction {
List,
Get {
identifier: String,
},
Create {
#[arg(short, long)]
name: String,
#[arg(short, long)]
identifier: String,
#[arg(short, long, default_value = "")]
description: String,
},
Update {
identifier: String,
#[arg(short, long)]
name: Option<String>,
#[arg(short, long)]
description: Option<String>,
},
}
#[derive(Subcommand)]
pub enum PageAction {
List {
#[arg(short, long)]
project: Option<String>,
#[arg(short, long)]
folder: Option<String>,
#[arg(short, long)]
label: Option<String>,
},
Get {
identifier: String,
},
Create {
#[arg(short, long)]
title: String,
#[arg(short, long)]
project: Option<String>,
#[arg(short, long)]
folder: Option<String>,
#[arg(short, long, default_value = "")]
content: String,
#[arg(short, long)]
labels: Option<String>,
},
Update {
identifier: String,
#[arg(short, long)]
title: Option<String>,
#[arg(short, long)]
content: Option<String>,
#[arg(short, long)]
folder: Option<String>,
#[arg(short, long)]
labels: Option<String>,
},
}
#[derive(Subcommand)]
pub enum ExportAction {
Issue {
identifier: String,
#[arg(short, long)]
output: PathBuf,
},
Page {
identifier: String,
#[arg(short, long)]
output: PathBuf,
},
Project {
project: String,
#[arg(short, long)]
output: PathBuf,
},
}
#[derive(Subcommand)]
pub enum CommentAction {
List {
identifier: String,
},
Add {
identifier: String,
#[arg(short, long)]
content: String,
#[arg(short, long)]
user: Option<String>,
},
}
#[derive(Subcommand)]
pub enum ModuleAction {
List {
#[arg(short, long)]
project: String,
},
Create {
#[arg(short, long)]
project: String,
#[arg(short, long)]
name: String,
#[arg(short, long, default_value = "")]
description: String,
#[arg(short, long, default_value = "active")]
status: String,
},
Update {
#[arg(short, long)]
project: String,
#[arg(short, long)]
name: String,
#[arg(long)]
new_name: Option<String>,
#[arg(short, long)]
description: Option<String>,
#[arg(short, long)]
status: Option<String>,
},
Delete {
#[arg(short, long)]
project: String,
#[arg(short, long)]
name: String,
},
}
#[derive(Subcommand)]
pub enum LabelAction {
List {
#[arg(short, long)]
project: String,
},
Create {
#[arg(short, long)]
project: String,
#[arg(short, long)]
name: String,
#[arg(short, long, default_value = "#6B7280")]
color: String,
},
Update {
#[arg(short, long)]
project: String,
#[arg(short, long)]
name: String,
#[arg(long)]
new_name: Option<String>,
#[arg(short, long)]
color: Option<String>,
},
Delete {
#[arg(short, long)]
project: String,
#[arg(short, long)]
name: String,
},
}
#[derive(Subcommand)]
pub enum FolderAction {
List {
#[arg(short, long)]
project: String,
},
Create {
#[arg(short, long)]
project: String,
#[arg(short, long)]
name: String,
},
Update {
#[arg(short, long)]
project: String,
#[arg(short, long)]
name: String,
#[arg(long)]
new_name: String,
},
Delete {
#[arg(short, long)]
project: String,
#[arg(short, long)]
name: String,
},
}
#[derive(Subcommand)]
pub enum InstanceAction {
Info,
Set {
#[arg(long)]
name: Option<String>,
#[arg(long)]
signups: Option<bool>,
#[arg(long = "signup-domains")]
signup_domains: Option<String>,
#[arg(long = "session-days")]
session_days: Option<i64>,
#[arg(long = "login-message")]
login_message: Option<String>,
#[arg(long = "auto-login")]
auto_login: Option<bool>,
#[arg(long = "authz-enforced")]
authz_enforced: Option<bool>,
},
}
#[derive(Subcommand)]
pub enum KeyAction {
Create {
#[arg(short, long)]
name: String,
#[arg(short, long)]
user: Option<String>,
#[arg(short, long)]
expires: Option<String>,
},
Assign {
#[arg(short, long)]
name: String,
#[arg(short, long)]
user: String,
},
List,
Revoke {
#[arg(short, long)]
name: String,
},
Rotate {
#[arg(short, long)]
name: String,
},
}
#[derive(Subcommand)]
pub enum MemberAction {
List {
#[arg(short, long)]
project: String,
},
Add {
#[arg(short, long, conflicts_with = "all", required_unless_present = "all")]
project: Option<String>,
#[arg(short, long)]
user: String,
#[arg(short, long, default_value = "viewer")]
role: String,
#[arg(long)]
all: bool,
},
Role {
#[arg(short, long)]
project: String,
#[arg(short, long)]
user: String,
#[arg(short, long)]
role: String,
},
Remove {
#[arg(short, long)]
project: String,
#[arg(short, long)]
user: String,
},
}
#[derive(Subcommand)]
pub enum UserAction {
Create {
#[arg(short, long)]
username: String,
#[arg(short, long)]
email: String,
#[arg(short, long)]
password: Option<String>,
#[arg(long)]
admin: bool,
#[arg(long)]
bot: bool,
},
List,
SetPassword {
#[arg(short, long)]
username: String,
#[arg(short, long)]
password: Option<String>,
},
Promote {
#[arg(short, long)]
username: String,
},
Demote {
#[arg(short, long)]
username: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn borrowed_labels_preserve_values_without_owning_strings() {
assert_eq!(
borrowed_labels(Some(" bug, ,urgent ")),
Some(vec!["bug", "urgent"])
);
assert_eq!(borrowed_labels(None), None);
}
#[test]
fn http_credential_prefers_explicit_and_ignores_blank_stored_values() {
assert_eq!(
resolve_http_credential(Some(" explicit "), || Some("stored".into())),
Ok(Some("explicit".into()))
);
assert_eq!(
resolve_http_credential(None, || Some(" stored ".into())),
Ok(Some("stored".into()))
);
assert_eq!(resolve_http_credential(None, || Some(" ".into())), Ok(None));
}
#[test]
fn http_credential_rejects_blank_explicit_value() {
let error = resolve_http_credential(Some(" "), || Some("stored".into())).unwrap_err();
assert_eq!(error.to_string(), "--api-key was provided but empty");
}
#[test]
fn http_credential_skips_stored_lookup_when_explicit_is_present() {
let mut loaded = false;
let credential = resolve_http_credential(Some("explicit"), || {
loaded = true;
Some("stored".into())
});
assert_eq!(credential.unwrap(), Some("explicit".into()));
assert!(!loaded);
}
#[test]
fn parses_http_backend_connection_options() {
let cli = Cli::try_parse_from([
"lific",
"--backend",
"http",
"--url",
"https://tracker.example.test",
"--api-key",
"key-123",
"issue",
"list",
"--project",
"LIF",
])
.expect("HTTP backend options should parse");
assert_eq!(cli.backend, BackendKind::Http);
assert_eq!(cli.url.as_deref(), Some("https://tracker.example.test"));
assert_eq!(cli.api_key.as_deref(), Some("key-123"));
}
fn env_fallback(var: &str) -> Option<String> {
std::env::var(var).ok()
}
#[test]
fn defaults_to_sql_backend_without_remote_options() {
let cli = Cli::try_parse_from(["lific", "project", "list"])
.expect("default backend options should parse");
assert_eq!(cli.backend, BackendKind::Sql);
assert_eq!(cli.url, env_fallback("LIFIC_URL"));
assert_eq!(cli.api_key, env_fallback("LIFIC_API_KEY"));
}
#[test]
fn rejects_unknown_backend_values() {
assert!(
Cli::try_parse_from(["lific", "--backend", "remote", "project", "list"]).is_err()
);
}
#[test]
fn parses_json_output_alongside_http_backend() {
let cli = Cli::try_parse_from([
"lific",
"--backend",
"http",
"--json",
"project",
"list",
])
.expect("JSON output should be transport independent");
assert_eq!(cli.backend, BackendKind::Http);
assert!(cli.json);
}
#[test]
fn parse_start_defaults() {
let cli = Cli::try_parse_from(["lific", "start"]).unwrap();
assert!(cli.config.is_none());
assert!(cli.db.is_none());
assert!(!cli.json);
match cli.command {
Command::Start { port, host } => {
assert!(port.is_none());
assert!(host.is_none());
}
_ => panic!("expected Start"),
}
}
#[test]
fn parse_start_with_overrides() {
let cli = Cli::try_parse_from([
"lific",
"--db",
"/tmp/test.db",
"start",
"--port",
"8080",
"--host",
"127.0.0.1",
])
.unwrap();
assert_eq!(cli.db, Some(PathBuf::from("/tmp/test.db")));
match cli.command {
Command::Start { port, host } => {
assert_eq!(port, Some(8080));
assert_eq!(host, Some("127.0.0.1".into()));
}
_ => panic!("expected Start"),
}
}
#[test]
fn parse_mcp() {
let cli = Cli::try_parse_from(["lific", "mcp"]).unwrap();
assert!(matches!(cli.command, Command::Mcp));
}
#[test]
fn parse_init() {
let cli = Cli::try_parse_from(["lific", "init"]).unwrap();
assert!(matches!(
cli.command,
Command::Init {
no_service: false,
here: false
}
));
}
#[test]
fn parse_init_no_service() {
let cli = Cli::try_parse_from(["lific", "init", "--no-service"]).unwrap();
assert!(matches!(
cli.command,
Command::Init {
no_service: true,
here: false
}
));
}
#[test]
fn parse_init_here() {
let cli = Cli::try_parse_from(["lific", "init", "--here"]).unwrap();
assert!(matches!(cli.command, Command::Init { here: true, .. }));
}
#[test]
fn parse_service_actions() {
for (arg, want) in [
("install", "Install"),
("uninstall", "Uninstall"),
("status", "Status"),
("stop", "Stop"),
("restart", "Restart"),
] {
let cli = Cli::try_parse_from(["lific", "service", arg]).unwrap();
let Command::Service { action } = cli.command else {
panic!("expected Service for {arg}");
};
let got = match action {
ServiceAction::Install => "Install",
ServiceAction::Uninstall => "Uninstall",
ServiceAction::Status => "Status",
ServiceAction::Stop => "Stop",
ServiceAction::Restart => "Restart",
};
assert_eq!(got, want);
}
}
#[test]
fn service_requires_an_action() {
assert!(Cli::try_parse_from(["lific", "service"]).is_err());
}
#[test]
fn parse_dump_defaults() {
let cli = Cli::try_parse_from(["lific", "dump"]).unwrap();
match cli.command {
Command::Dump { out } => assert!(out.is_none()),
_ => panic!("expected Dump"),
}
}
#[test]
fn parse_dump_with_out() {
let cli = Cli::try_parse_from(["lific", "dump", "--out", "/tmp/backups"]).unwrap();
match cli.command {
Command::Dump { out } => assert_eq!(out, Some(PathBuf::from("/tmp/backups"))),
_ => panic!("expected Dump"),
}
}
#[test]
fn parse_restore_defaults() {
let cli = Cli::try_parse_from(["lific", "restore", "/tmp/b.tar.gz"]).unwrap();
match cli.command {
Command::Restore { archive, force } => {
assert_eq!(archive, PathBuf::from("/tmp/b.tar.gz"));
assert!(!force);
}
_ => panic!("expected Restore"),
}
}
#[test]
fn parse_restore_force() {
let cli =
Cli::try_parse_from(["lific", "restore", "/tmp/b.tar.gz", "--force"]).unwrap();
match cli.command {
Command::Restore { archive, force } => {
assert_eq!(archive, PathBuf::from("/tmp/b.tar.gz"));
assert!(force);
}
_ => panic!("expected Restore"),
}
}
#[test]
fn parse_doctor_no_key() {
let cli = Cli::try_parse_from(["lific", "doctor"]).unwrap();
match cli.command {
Command::Doctor { key } => {
assert_eq!(key, env_fallback("LIFIC_API_KEY"));
}
_ => panic!("expected Doctor"),
}
}
#[test]
fn parse_login_defaults() {
let cli = Cli::try_parse_from(["lific", "login"]).unwrap();
match cli.command {
Command::Login {
url,
non_interactive,
complete,
label,
no_store,
} => {
assert_eq!(url, env_fallback("LIFIC_URL"));
assert!(!non_interactive);
assert!(complete.is_none());
assert!(label.is_none());
assert!(!no_store);
}
_ => panic!("expected Login"),
}
}
#[test]
fn parse_login_all_flags() {
let cli = Cli::try_parse_from([
"lific",
"login",
"--url",
"http://127.0.0.1:3998",
"--non-interactive",
"--label",
"my-laptop",
"--no-store",
])
.unwrap();
match cli.command {
Command::Login {
url,
non_interactive,
complete,
label,
no_store,
} => {
assert_eq!(url, Some("http://127.0.0.1:3998".into()));
assert!(non_interactive);
assert!(complete.is_none());
assert_eq!(label, Some("my-laptop".into()));
assert!(no_store);
}
_ => panic!("expected Login"),
}
}
#[test]
fn parse_login_complete() {
let cli = Cli::try_parse_from([
"lific", "login", "--complete", "abc123def", "--url", "http://h:1",
])
.unwrap();
match cli.command {
Command::Login {
complete, url, ..
} => {
assert_eq!(complete, Some("abc123def".into()));
assert_eq!(url, Some("http://h:1".into()));
}
_ => panic!("expected Login"),
}
}
#[test]
fn parse_logout_defaults() {
let cli = Cli::try_parse_from(["lific", "logout"]).unwrap();
match cli.command {
Command::Logout { url } => assert_eq!(url, env_fallback("LIFIC_URL")),
_ => panic!("expected Logout"),
}
}
#[test]
fn parse_logout_with_url() {
let cli = Cli::try_parse_from(["lific", "logout", "--url", "https://lific.example"]).unwrap();
match cli.command {
Command::Logout { url } => assert_eq!(url, Some("https://lific.example".into())),
_ => panic!("expected Logout"),
}
}
#[test]
fn parse_doctor_with_key_flag() {
let cli = Cli::try_parse_from(["lific", "doctor", "--key", "lific_sk-live-abc"]).unwrap();
match cli.command {
Command::Doctor { key } => assert_eq!(key, Some("lific_sk-live-abc".into())),
_ => panic!("expected Doctor"),
}
}
#[test]
fn parse_connect_defaults() {
let cli = Cli::try_parse_from(["lific", "connect"]).unwrap();
match cli.command {
Command::Connect {
clients,
scope,
stdio,
oauth,
url,
key,
user,
yes,
dry_run,
skip_agents,
} => {
assert!(clients.is_empty());
assert_eq!(scope, "global");
assert!(!stdio);
assert!(!oauth);
assert_eq!(url, env_fallback("LIFIC_URL"));
assert!(key.is_none());
assert!(user.is_none());
assert!(!yes);
assert!(!dry_run);
assert!(!skip_agents);
}
_ => panic!("expected Connect"),
}
}
#[test]
fn parse_connect_oauth_flag() {
let cli = Cli::try_parse_from([
"lific", "connect", "--oauth", "--client", "opencode", "--yes",
])
.unwrap();
match cli.command {
Command::Connect {
oauth,
clients,
stdio,
key,
..
} => {
assert!(oauth, "--oauth must parse");
assert_eq!(clients, vec!["opencode".to_string()]);
assert!(!stdio);
assert!(key.is_none());
}
_ => panic!("expected Connect"),
}
}
#[test]
fn parse_connect_repeatable_client_and_flags() {
let cli = Cli::try_parse_from([
"lific", "connect",
"--client", "opencode",
"--client", "codex",
"--scope", "project",
"--stdio",
"--url", "http://127.0.0.1:9000/mcp",
"--key", "lific_sk-live-K",
"--user", "blake",
"--yes",
"--dry-run",
"--skip-agents",
])
.unwrap();
match cli.command {
Command::Connect {
clients,
scope,
stdio,
oauth,
url,
key,
user,
yes,
dry_run,
skip_agents,
} => {
assert_eq!(clients, vec!["opencode".to_string(), "codex".to_string()]);
assert_eq!(scope, "project");
assert!(stdio);
assert!(!oauth);
assert_eq!(url, Some("http://127.0.0.1:9000/mcp".into()));
assert_eq!(key, Some("lific_sk-live-K".into()));
assert_eq!(user, Some("blake".into()));
assert!(yes);
assert!(dry_run);
assert!(skip_agents);
}
_ => panic!("expected Connect"),
}
}
#[test]
fn parse_agents_md_defaults() {
let cli = Cli::try_parse_from(["lific", "agents-md"]).unwrap();
match cli.command {
Command::AgentsMd { path, project } => {
assert!(path.is_none());
assert!(project.is_none());
}
_ => panic!("expected AgentsMd"),
}
}
#[test]
fn parse_agents_md_with_path_and_project() {
let cli = Cli::try_parse_from([
"lific", "agents-md", "--path", "docs/AGENTS.md", "--project", "LIF",
])
.unwrap();
match cli.command {
Command::AgentsMd { path, project } => {
assert_eq!(path, Some(PathBuf::from("docs/AGENTS.md")));
assert_eq!(project, Some("LIF".into()));
}
_ => panic!("expected AgentsMd"),
}
}
#[test]
fn parse_completion_fish() {
let cli = Cli::try_parse_from(["lific", "completion", "fish"]).unwrap();
match cli.command {
Command::Completion { shell } => {
assert_eq!(shell, clap_complete::Shell::Fish);
}
_ => panic!("expected Completion"),
}
}
#[test]
fn parse_completion_bash() {
let cli = Cli::try_parse_from(["lific", "completion", "bash"]).unwrap();
assert!(matches!(
cli.command,
Command::Completion {
shell: clap_complete::Shell::Bash,
}
));
}
#[test]
fn parse_completion_rejects_garbage() {
assert!(Cli::try_parse_from(["lific", "completion", "notashell"]).is_err());
}
#[test]
fn parse_instance_info() {
let cli = Cli::try_parse_from(["lific", "instance", "info"]).unwrap();
assert!(matches!(
cli.command,
Command::Instance {
action: InstanceAction::Info,
}
));
}
#[test]
fn parse_instance_set() {
let cli = Cli::try_parse_from([
"lific", "instance", "set",
"--name", "Acme Eng",
"--signups", "false",
"--signup-domains", "acme.com,sub.acme.com",
"--session-days", "14",
"--login-message", "Ask #it for access",
"--auto-login", "true",
"--authz-enforced", "true",
])
.unwrap();
match cli.command {
Command::Instance {
action:
InstanceAction::Set {
name,
signups,
signup_domains,
session_days,
login_message,
auto_login,
authz_enforced,
},
} => {
assert_eq!(name, Some("Acme Eng".into()));
assert_eq!(signups, Some(false));
assert_eq!(signup_domains, Some("acme.com,sub.acme.com".into()));
assert_eq!(session_days, Some(14));
assert_eq!(login_message, Some("Ask #it for access".into()));
assert_eq!(auto_login, Some(true));
assert_eq!(authz_enforced, Some(true));
}
_ => panic!("expected Instance Set"),
}
}
#[test]
fn parse_key_create() {
let cli = Cli::try_parse_from(["lific", "key", "create", "--name", "test-key"]).unwrap();
match cli.command {
Command::Key {
action: KeyAction::Create {
name,
user,
expires,
},
} => {
assert_eq!(name, "test-key");
assert!(user.is_none());
assert!(expires.is_none());
}
_ => panic!("expected Key Create"),
}
}
#[test]
fn parse_key_create_with_user() {
let cli = Cli::try_parse_from([
"lific", "key", "create", "--name", "my-key", "--user", "blake",
])
.unwrap();
match cli.command {
Command::Key {
action: KeyAction::Create {
name,
user,
expires,
},
} => {
assert_eq!(name, "my-key");
assert_eq!(user, Some("blake".into()));
assert!(expires.is_none());
}
_ => panic!("expected Key Create"),
}
}
#[test]
fn parse_key_create_with_expires() {
let cli = Cli::try_parse_from([
"lific",
"key",
"create",
"--name",
"temp-key",
"--expires",
"2026-12-31",
])
.unwrap();
match cli.command {
Command::Key {
action: KeyAction::Create {
name,
user,
expires,
},
} => {
assert_eq!(name, "temp-key");
assert!(user.is_none());
assert_eq!(expires, Some("2026-12-31".into()));
}
_ => panic!("expected Key Create"),
}
}
#[test]
fn parse_key_assign() {
let cli = Cli::try_parse_from([
"lific", "key", "assign", "--name", "opencode", "--user", "blake",
])
.unwrap();
match cli.command {
Command::Key {
action: KeyAction::Assign { name, user },
} => {
assert_eq!(name, "opencode");
assert_eq!(user, "blake");
}
_ => panic!("expected Key Assign"),
}
}
#[test]
fn parse_key_revoke() {
let cli = Cli::try_parse_from(["lific", "key", "revoke", "--name", "old"]).unwrap();
match cli.command {
Command::Key {
action: KeyAction::Revoke { name },
} => assert_eq!(name, "old"),
_ => panic!("expected Key Revoke"),
}
}
#[test]
fn parse_user_create() {
let cli = Cli::try_parse_from([
"lific",
"user",
"create",
"--username",
"blake",
"--email",
"b@test.com",
"--password",
"secret123",
"--admin",
])
.unwrap();
match cli.command {
Command::User {
action:
UserAction::Create {
username,
email,
password,
admin,
bot,
},
} => {
assert_eq!(username, "blake");
assert_eq!(email, "b@test.com");
assert_eq!(password, Some("secret123".into()));
assert!(admin);
assert!(!bot);
}
_ => panic!("expected User Create"),
}
}
#[test]
fn parse_user_list() {
let cli = Cli::try_parse_from(["lific", "user", "list"]).unwrap();
assert!(matches!(
cli.command,
Command::User {
action: UserAction::List,
}
));
}
#[test]
fn parse_user_set_password() {
let cli = Cli::try_parse_from([
"lific", "user", "set-password", "--username", "blake",
])
.unwrap();
match cli.command {
Command::User {
action: UserAction::SetPassword { username, password },
} => {
assert_eq!(username, "blake");
assert!(password.is_none(), "password omitted → interactive prompt");
}
_ => panic!("expected User SetPassword"),
}
}
#[test]
fn parse_user_set_password_with_password_flag() {
let cli = Cli::try_parse_from([
"lific", "user", "set-password", "-u", "blake", "-p", "newpass123",
])
.unwrap();
match cli.command {
Command::User {
action: UserAction::SetPassword { username, password },
} => {
assert_eq!(username, "blake");
assert_eq!(password, Some("newpass123".into()));
}
_ => panic!("expected User SetPassword"),
}
}
#[test]
fn parse_member_add_defaults_to_viewer() {
let cli = Cli::try_parse_from([
"lific", "member", "add", "--project", "LIF", "--user", "bob",
])
.unwrap();
match cli.command {
Command::Member {
action: MemberAction::Add { project, user, role, all },
} => {
assert_eq!(project, Some("LIF".into()));
assert_eq!(user, "bob");
assert_eq!(role, "viewer");
assert!(!all);
}
_ => panic!("expected Member Add"),
}
}
#[test]
fn parse_member_add_all_projects() {
let cli = Cli::try_parse_from([
"lific", "member", "add", "--all", "--user", "bob", "--role", "maintainer",
])
.unwrap();
match cli.command {
Command::Member {
action: MemberAction::Add { project, user, role, all },
} => {
assert!(project.is_none());
assert_eq!(user, "bob");
assert_eq!(role, "maintainer");
assert!(all);
}
_ => panic!("expected Member Add"),
}
}
#[test]
fn parse_member_add_requires_project_or_all() {
assert!(
Cli::try_parse_from(["lific", "member", "add", "--user", "bob"]).is_err(),
"one of --project / --all is required"
);
assert!(
Cli::try_parse_from([
"lific", "member", "add", "--project", "LIF", "--all", "--user", "bob",
])
.is_err(),
"--project conflicts with --all"
);
}
#[test]
fn parse_member_list_role_remove() {
assert!(matches!(
Cli::try_parse_from(["lific", "member", "list", "--project", "LIF"])
.unwrap()
.command,
Command::Member { action: MemberAction::List { .. } }
));
match Cli::try_parse_from([
"lific", "member", "role", "-p", "LIF", "-u", "bob", "-r", "lead",
])
.unwrap()
.command
{
Command::Member {
action: MemberAction::Role { project, user, role },
} => {
assert_eq!((project.as_str(), user.as_str(), role.as_str()), ("LIF", "bob", "lead"));
}
_ => panic!("expected Member Role"),
}
assert!(matches!(
Cli::try_parse_from(["lific", "member", "remove", "-p", "LIF", "-u", "bob"])
.unwrap()
.command,
Command::Member { action: MemberAction::Remove { .. } }
));
}
#[test]
fn parse_global_config_flag() {
let cli = Cli::try_parse_from(["lific", "--config", "/etc/lific.toml", "start"]).unwrap();
assert_eq!(cli.config, Some(PathBuf::from("/etc/lific.toml")));
}
#[test]
fn missing_subcommand_errors() {
assert!(Cli::try_parse_from(["lific"]).is_err());
}
#[test]
fn parse_issue_list() {
let cli = Cli::try_parse_from(["lific", "issue", "list", "--project", "LIF"]).unwrap();
match cli.command {
Command::Issue {
action:
IssueAction::List {
project,
status,
priority,
module,
label,
workable,
limit,
},
} => {
assert_eq!(project, "LIF");
assert!(status.is_none());
assert!(priority.is_none());
assert!(module.is_none());
assert!(label.is_none());
assert!(!workable);
assert!(limit.is_none());
}
_ => panic!("expected Issue List"),
}
}
#[test]
fn parse_issue_list_with_filters() {
let cli = Cli::try_parse_from([
"lific",
"issue",
"list",
"--project",
"LIF",
"--status",
"active",
"--priority",
"urgent",
"--workable",
"--limit",
"10",
])
.unwrap();
match cli.command {
Command::Issue {
action:
IssueAction::List {
project,
status,
priority,
workable,
limit,
..
},
} => {
assert_eq!(project, "LIF");
assert_eq!(status, Some("active".into()));
assert_eq!(priority, Some("urgent".into()));
assert!(workable);
assert_eq!(limit, Some(10));
}
_ => panic!("expected Issue List"),
}
}
#[test]
fn parse_issue_get() {
let cli = Cli::try_parse_from(["lific", "issue", "get", "LIF-42"]).unwrap();
match cli.command {
Command::Issue {
action: IssueAction::Get { identifier },
} => assert_eq!(identifier, "LIF-42"),
_ => panic!("expected Issue Get"),
}
}
#[test]
fn parse_issue_create() {
let cli = Cli::try_parse_from([
"lific",
"issue",
"create",
"--project",
"LIF",
"--title",
"Fix bug",
"--priority",
"high",
"--labels",
"bug,urgent",
])
.unwrap();
match cli.command {
Command::Issue {
action:
IssueAction::Create {
project,
title,
priority,
labels,
status,
..
},
} => {
assert_eq!(project, "LIF");
assert_eq!(title, "Fix bug");
assert_eq!(priority, "high");
assert_eq!(status, "backlog");
assert_eq!(labels, Some("bug,urgent".into()));
}
_ => panic!("expected Issue Create"),
}
}
#[test]
fn parse_issue_update() {
let cli = Cli::try_parse_from(["lific", "issue", "update", "LIF-42", "--status", "done"])
.unwrap();
match cli.command {
Command::Issue {
action:
IssueAction::Update {
identifier,
status,
title,
..
},
} => {
assert_eq!(identifier, "LIF-42");
assert_eq!(status, Some("done".into()));
assert!(title.is_none());
}
_ => panic!("expected Issue Update"),
}
}
#[test]
fn parse_project_list() {
let cli = Cli::try_parse_from(["lific", "project", "list"]).unwrap();
assert!(matches!(
cli.command,
Command::Project {
action: ProjectAction::List
}
));
}
#[test]
fn parse_project_create() {
let cli = Cli::try_parse_from([
"lific",
"project",
"create",
"--name",
"My Project",
"--identifier",
"MP",
])
.unwrap();
match cli.command {
Command::Project {
action:
ProjectAction::Create {
name,
identifier,
description,
},
} => {
assert_eq!(name, "My Project");
assert_eq!(identifier, "MP");
assert_eq!(description, "");
}
_ => panic!("expected Project Create"),
}
}
#[test]
fn parse_search() {
let cli =
Cli::try_parse_from(["lific", "search", "auth flow", "--project", "LIF"]).unwrap();
match cli.command {
Command::Search {
query,
project,
limit,
} => {
assert_eq!(query, "auth flow");
assert_eq!(project, Some("LIF".into()));
assert!(limit.is_none());
}
_ => panic!("expected Search"),
}
}
#[test]
fn parse_export_project() {
let cli =
Cli::try_parse_from(["lific", "export", "project", "LIF", "--output", "/tmp/out"])
.unwrap();
match cli.command {
Command::Export {
action: ExportAction::Project { project, output },
} => {
assert_eq!(project, "LIF");
assert_eq!(output, PathBuf::from("/tmp/out"));
}
_ => panic!("expected Export Project"),
}
}
#[test]
fn parse_comment_list() {
let cli = Cli::try_parse_from(["lific", "comment", "list", "LIF-42"]).unwrap();
match cli.command {
Command::Comment {
action: CommentAction::List { identifier },
} => assert_eq!(identifier, "LIF-42"),
_ => panic!("expected Comment List"),
}
}
#[test]
fn parse_comment_add() {
let cli = Cli::try_parse_from([
"lific",
"comment",
"add",
"LIF-42",
"--content",
"Looking into this",
])
.unwrap();
match cli.command {
Command::Comment {
action:
CommentAction::Add {
identifier,
content,
user,
},
} => {
assert_eq!(identifier, "LIF-42");
assert_eq!(content, "Looking into this");
assert!(user.is_none());
}
_ => panic!("expected Comment Add"),
}
}
#[test]
fn parse_module_list() {
let cli = Cli::try_parse_from(["lific", "module", "list", "--project", "LIF"]).unwrap();
match cli.command {
Command::Module {
action: ModuleAction::List { project },
} => assert_eq!(project, "LIF"),
_ => panic!("expected Module List"),
}
}
#[test]
fn parse_module_create() {
let cli = Cli::try_parse_from([
"lific",
"module",
"create",
"--project",
"LIF",
"--name",
"Core",
])
.unwrap();
match cli.command {
Command::Module {
action:
ModuleAction::Create {
project,
name,
status,
..
},
} => {
assert_eq!(project, "LIF");
assert_eq!(name, "Core");
assert_eq!(status, "active");
}
_ => panic!("expected Module Create"),
}
}
#[test]
fn parse_label_create() {
let cli = Cli::try_parse_from([
"lific",
"label",
"create",
"--project",
"LIF",
"--name",
"bug",
"--color",
"#EF4444",
])
.unwrap();
match cli.command {
Command::Label {
action:
LabelAction::Create {
project,
name,
color,
},
} => {
assert_eq!(project, "LIF");
assert_eq!(name, "bug");
assert_eq!(color, "#EF4444");
}
_ => panic!("expected Label Create"),
}
}
#[test]
fn parse_folder_create() {
let cli = Cli::try_parse_from([
"lific",
"folder",
"create",
"--project",
"LIF",
"--name",
"Architecture",
])
.unwrap();
match cli.command {
Command::Folder {
action: FolderAction::Create { project, name },
} => {
assert_eq!(project, "LIF");
assert_eq!(name, "Architecture");
}
_ => panic!("expected Folder Create"),
}
}
#[test]
fn parse_json_flag() {
let cli = Cli::try_parse_from(["lific", "--json", "project", "list"]).unwrap();
assert!(cli.json);
assert!(matches!(
cli.command,
Command::Project {
action: ProjectAction::List
}
));
}
#[test]
fn json_flag_defaults_to_false() {
let cli = Cli::try_parse_from(["lific", "project", "list"]).unwrap();
assert!(!cli.json);
}
}