pub mod exec;
use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[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,
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
pub enum Command {
Start {
#[arg(short, long)]
port: Option<u16>,
#[arg(long)]
host: Option<String>,
},
Mcp,
Init,
Key {
#[command(subcommand)]
action: KeyAction,
},
User {
#[command(subcommand)]
action: UserAction,
},
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,
},
}
#[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>,
},
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,
},
Update {
identifier: String,
#[arg(short, long)]
title: Option<String>,
#[arg(short, long)]
content: Option<String>,
#[arg(short, long)]
folder: 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 KeyAction {
Create {
#[arg(short, long)]
name: String,
#[arg(short, long)]
user: 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 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,
Promote {
#[arg(short, long)]
username: String,
},
Demote {
#[arg(short, long)]
username: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[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));
}
#[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 },
} => {
assert_eq!(name, "test-key");
assert!(user.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 },
} => {
assert_eq!(name, "my-key");
assert_eq!(user, Some("blake".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_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);
}
}