#![allow(
missing_debug_implementations,
missing_docs,
rustdoc::missing_crate_level_docs,
clippy::missing_errors_doc,
clippy::missing_panics_doc
)]
use anyhow::{Context, Result, bail};
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
use std::io::{Read, Write};
use std::path::PathBuf;
use vissue_core::config::Layout;
use vissue_core::error::Error;
use vissue_core::mirror::{self, Format};
use vissue_core::ops::{self, CreateOpts, RejectOpts, UpdatePred};
use vissue_core::router::Router;
use vissue_core::store;
use vissue_core::{agent, events, report};
mod rofi;
macro_rules! emit {
($($arg:tt)*) => {
write_stdout(format_args!($($arg)*))?
};
}
macro_rules! emitln {
($($arg:tt)*) => {
write_stdout(format_args!("{}\n", format_args!($($arg)*)))?
};
}
fn write_stdout(args: std::fmt::Arguments<'_>) -> Result<()> {
std::io::stdout().lock().write_fmt(args)?;
Ok(())
}
fn is_broken_pipe(error: &anyhow::Error) -> bool {
error
.chain()
.filter_map(|cause| cause.downcast_ref::<std::io::Error>())
.any(|io| io.kind() == std::io::ErrorKind::BrokenPipe)
}
#[derive(Parser)]
#[command(
name = "vissue",
version,
about = "Plain-text issue tracking over per-project orgmode files"
)]
struct Cli {
#[arg(long, global = true)]
root: Option<PathBuf>,
#[arg(long, global = true)]
prefix: Option<String>,
#[arg(long, global = true)]
no_route: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Create {
title: String,
#[arg(short = 'p', short_alias = 'P', long)]
project: Option<String>,
#[arg(long)]
priority: Option<char>,
#[arg(short = 't', long = "type")]
issue_type: Option<String>,
#[arg(long)]
deadline: Option<String>,
#[arg(long)]
scheduled: Option<String>,
#[arg(long)]
tags: Option<String>,
#[arg(long)]
parent: Option<String>,
#[arg(short, long)]
quiet: bool,
#[arg(long, conflicts_with = "body_file")]
body: Option<String>,
#[arg(long)]
body_file: Option<String>,
},
Q {
title: String,
#[arg(short = 'p', short_alias = 'P', long)]
project: Option<String>,
#[arg(short = 't', long = "type")]
issue_type: Option<String>,
#[arg(long)]
parent: Option<String>,
},
List {
#[arg(short = 'p', short_alias = 'P', long)]
project: Option<String>,
#[arg(short, long)]
state: Option<String>,
#[arg(long)]
json: bool,
},
Show {
id: String,
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "json")]
org: bool,
},
Update {
id: String,
#[arg(short, long)]
state: Option<String>,
#[arg(long)]
priority: Option<char>,
#[arg(long)]
block: Option<String>,
#[arg(long)]
unblock: Option<String>,
#[arg(long)]
if_state: Option<String>,
#[arg(long)]
if_gen: Option<u64>,
},
Resolve {
id: String,
#[arg(short, long)]
state: String,
},
Reject {
id: String,
#[arg(long, required_unless_present = "project", conflicts_with = "project")]
to: Option<String>,
#[arg(
short = 'p',
short_alias = 'P',
long,
required_unless_present = "to",
requires = "title"
)]
project: Option<String>,
#[arg(required_unless_present = "to", conflicts_with = "to")]
title: Option<String>,
#[arg(long)]
reason: Option<String>,
},
Ready {
#[arg(short = 'p', short_alias = 'P', long)]
project: Option<String>,
#[arg(long)]
json: bool,
},
Claim {
id: String,
#[arg(long)]
force: bool,
},
Vote {
id: String,
#[arg(long = "for", value_name = "CHOICE")]
choice: Option<String>,
},
Deed {
id: String,
#[arg(long = "add", value_name = "DEED")]
add: Vec<String>,
#[arg(long = "remove", value_name = "DEED")]
remove: Vec<String>,
},
Recall {
id: String,
#[arg(short, long, default_value = "1")]
depth: usize,
#[arg(long, conflicts_with = "json")]
deeds_only: bool,
#[arg(long)]
excerpts: bool,
#[arg(long)]
json: bool,
},
Consensus {
id: String,
#[arg(long)]
children: bool,
#[arg(long)]
gate: bool,
#[arg(long)]
json: bool,
},
Note {
id: String,
#[arg(required = true, num_args = 1..)]
text: Vec<String>,
},
Append {
id: String,
#[arg(long, conflicts_with = "file")]
text: Option<String>,
#[arg(long)]
file: Option<String>,
},
Claims {
#[arg(long)]
by: Option<String>,
#[arg(short = 'p', short_alias = 'P', long)]
project: Option<String>,
#[arg(long)]
json: bool,
},
Fold {
file: PathBuf,
#[arg(short = 'p', short_alias = 'P', long)]
project: Option<String>,
},
Agenda {
#[arg(short, long, default_value = "14")]
days: i64,
#[arg(short = 'p', short_alias = 'P', long)]
project: Option<String>,
#[arg(long)]
json: bool,
},
Hygiene {
#[arg(long)]
stale_days: Option<i64>,
},
Whoami,
#[command(name = "waiting-on")]
WaitingOn { id: String },
#[command(name = "body-excerpt")]
BodyExcerpt {
id: String,
#[arg(long)]
json: bool,
},
Search {
query: String,
#[arg(short = 'n', long, default_value = "20")]
limit: usize,
#[arg(long)]
json: bool,
},
Children {
id: String,
#[arg(long)]
json: bool,
},
Ancestors {
id: String,
#[arg(short, long, default_value = "3")]
depth: usize,
#[arg(long)]
json: bool,
},
Impact {
id: String,
#[arg(short, long, default_value = "3")]
depth: usize,
#[arg(long)]
json: bool,
},
Related {
id: String,
#[arg(short, long, default_value = "2")]
depth: usize,
#[arg(short = 'n', long, default_value = "20")]
limit: usize,
#[arg(long, default_value = "text")]
format: String,
},
Stale {
#[arg(short, long, default_value = "30")]
days: i64,
#[arg(short = 'p', short_alias = 'P', long)]
project: Option<String>,
},
Count {
#[arg(short = 'p', short_alias = 'P', long)]
project: Option<String>,
#[arg(short, long)]
state: Option<String>,
#[arg(short, long)]
ready: bool,
},
Export {
#[arg(short = 'p', short_alias = 'P', long)]
project: Option<String>,
},
Tree {
id: String,
#[arg(short, long, default_value = "ascii")]
format: String,
#[arg(long, conflicts_with = "format")]
json: bool,
},
Cycles,
Graph {
#[arg(short = 'p', short_alias = 'P', long)]
project: Option<String>,
},
Refile {
id: String,
#[arg(long)]
to: String,
},
Backlinks {
id: String,
#[arg(long)]
json: bool,
},
Roadmap {
#[arg(short = 'p', short_alias = 'P', long)]
project: Option<String>,
},
Check,
Normalize {
#[arg(short = 'p', short_alias = 'P', long)]
project: Option<String>,
#[arg(long)]
dry_run: bool,
},
Digest {
#[arg(short = 'p', short_alias = 'P', long = "project")]
projects: Vec<String>,
#[arg(long)]
json: bool,
#[arg(short, long)]
quiet: bool,
},
Mirror {
#[arg(short = 'p', short_alias = 'P', long = "project")]
projects: Vec<String>,
#[arg(short, long, required_unless_present = "check")]
out: Option<String>,
#[arg(long, conflicts_with = "out")]
check: Option<PathBuf>,
#[arg(short, long, default_value = "org")]
format: String,
#[arg(short, long)]
state: Option<String>,
},
Events {
#[arg(long, default_value_t = 0)]
since: u64,
#[arg(short = 'n', long, default_value_t = 50)]
limit: usize,
},
Ping {
#[arg(long)]
detail: Option<String>,
},
Wait {
#[arg(long, default_value_t = 0)]
last: u64,
#[arg(long)]
id: Option<String>,
#[arg(long)]
until_terminal: bool,
#[arg(long, default_value_t = 200)]
poll_ms: u64,
#[arg(long, default_value_t = 10_000)]
timeout_ms: u64,
},
Gen,
Projects {
#[arg(long)]
json: bool,
},
#[command(hide = true)]
Surface,
Identity,
Serve(ServeArgs),
Tui {
#[arg(long)]
offline: bool,
#[arg(short = 's', long)]
socket: Option<PathBuf>,
},
Hud {
#[arg(long, default_value = "ready")]
mode: String,
#[arg(long)]
offline: bool,
#[arg(long)]
foreground: bool,
#[arg(long, group = "summon")]
toggle: bool,
#[arg(long, group = "summon")]
show: bool,
#[arg(long, group = "summon")]
hide: bool,
#[arg(long)]
iced: bool,
#[arg(long)]
rofi: bool,
#[arg(short = 's', long)]
socket: Option<PathBuf>,
},
Completions {
#[arg(value_enum)]
shell: CompletionShell,
},
Man,
Keys {
#[arg(long)]
check: bool,
#[arg(long)]
occupancy: bool,
},
}
#[derive(Args)]
struct ServeArgs {
#[arg(short = 'd', long)]
detach: bool,
#[arg(long, hide = true, alias = "no-detach")]
foreground: bool,
#[arg(short = 's', long, global = true)]
socket: Option<PathBuf>,
#[command(subcommand)]
action: Option<ServeAction>,
}
#[derive(Subcommand)]
enum ServeAction {
Stop,
Restart,
Status {
#[arg(long)]
json: bool,
},
}
#[derive(Copy, Clone, PartialEq, Eq, ValueEnum)]
enum CompletionShell {
Bash,
Elvish,
Fish,
Powershell,
Zsh,
}
#[global_allocator]
static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
fn main() {
if let Err(e) = run() {
if is_broken_pipe(&e) {
return;
}
eprintln!("vissue: {e:#}");
std::process::exit(1);
}
}
fn build_router(cli: &Cli) -> Result<Router> {
let default = Layout::resolve(cli.root.as_deref(), cli.prefix.as_deref())?;
let routed = if cli.no_route {
Router::unrouted(default.clone())
} else {
Router::load(default.clone())?
};
Ok(routed)
}
fn create_routed(
router: &Router,
project: &str,
title: &str,
opts: CreateOpts<'_>,
) -> Result<String> {
let pref = router.route(project);
let twins = router.extra_id_paths_for(&pref.dir);
let opts = CreateOpts {
extra_id_paths: &twins,
..opts
};
Ok(ops::create(&pref.layout, &pref.dir, title, opts)?)
}
fn emit_shape<T, S, E, F>(
json: bool,
structured: impl FnOnce() -> std::result::Result<T, E>,
text: impl FnOnce() -> std::result::Result<S, F>,
) -> Result<()>
where
T: serde::Serialize,
S: std::fmt::Display,
E: Into<anyhow::Error>,
F: Into<anyhow::Error>,
{
if json {
let value = structured().map_err(Into::into)?;
emitln!("{}", serde_json::to_string_pretty(&value)?);
} else {
emit!("{}", text().map_err(Into::into)?);
}
Ok(())
}
fn with_catalog<T>(
layout: &Layout,
f: impl FnOnce(&vissue_core::catalog::CatalogService<'_>) -> vissue_core::Result<T>,
) -> Result<T> {
let recs = vissue_core::catalog::load_recs(layout)?;
let svc = vissue_core::catalog::CatalogService::from_recs(&recs);
Ok(f(&svc)?)
}
fn layout_for_id(router: &Router, id: &str) -> Result<Layout> {
Ok(router.find_by_id(id)?.layout)
}
struct RejectDest {
layout: Layout,
project: Option<String>,
extra_id_paths: Vec<PathBuf>,
}
fn reject_destination(
router: &Router,
src: &Layout,
to: Option<&str>,
project: Option<&str>,
) -> Result<RejectDest> {
if let Some(to) = to {
return Ok(RejectDest {
layout: layout_for_id(router, to)?,
project: project.map(str::to_string),
extra_id_paths: Vec::new(),
});
}
let Some(project) = project else {
return Ok(RejectDest {
layout: src.clone(),
project: None,
extra_id_paths: Vec::new(),
});
};
let pref = router.route(project);
let extra_id_paths = router.extra_id_paths_for(&pref.dir);
Ok(RejectDest {
layout: pref.layout,
project: Some(pref.dir),
extra_id_paths,
})
}
fn run_wait(
router: &Router,
layout: &Layout,
last: u64,
id: Option<String>,
until_terminal: bool,
poll_ms: u64,
timeout_ms: u64,
) -> Result<()> {
if until_terminal {
let Some(id) = id else {
bail!("--until-terminal requires --id");
};
let found = layout_for_id(router, &id)?;
match events::wait_until_terminal(&found, &id, poll_ms, timeout_ms)? {
events::TerminalWait::Done { generation } => {
emitln!("DONE {generation}");
}
events::TerminalWait::Cancelled { generation } => {
emitln!("CANCELLED {generation}");
}
events::TerminalWait::Timeout { generation, state } => {
emitln!("TIMEOUT {state} {generation}");
std::process::exit(2);
}
}
} else {
let generation = events::wait_generation(layout, last, poll_ms, timeout_ms)?;
emitln!("{generation}");
if generation <= last {
std::process::exit(2);
}
}
Ok(())
}
fn run_mirror(
layout: &Layout,
projects: &[String],
out: Option<String>,
check: Option<PathBuf>,
format: &str,
state: Option<String>,
) -> Result<()> {
if let Some(path) = check {
let verdict = mirror::check(layout, &path, projects)?;
emit!("{}", verdict.report);
if !verdict.fresh {
std::process::exit(1);
}
return Ok(());
}
let out = out.expect("clap requires --out unless --check is given");
let text = mirror::render(layout, projects, Format::parse(format)?, state.as_deref())?;
if out == "-" {
emit!("{text}");
} else {
let path = PathBuf::from(&out);
store::replace_file_atomically(&path, &text)?;
emitln!("wrote {}", path.display());
}
Ok(())
}
struct HudRequest {
mode: String,
offline: bool,
foreground: bool,
toggle: bool,
show: bool,
hide: bool,
iced: bool,
rofi: bool,
socket: Option<PathBuf>,
}
fn run_hud(layout: Layout, request: HudRequest) -> Result<()> {
let HudRequest {
mode,
offline,
foreground,
toggle,
show,
hide,
iced,
rofi,
socket,
} = request;
let use_rofi = rofi && !iced;
if use_rofi {
let _ = (toggle, show, hide, offline, foreground, socket);
let mode = rofi::Mode::parse(&mode)?;
rofi::run(rofi::RofiOpts::from_env(layout, mode)?)?;
} else {
if !offline && cfg!(not(unix)) {
bail!("vissue hud is Unix-only");
}
exec_hud(ExecHud {
layout,
socket,
offline,
foreground,
toggle,
show,
hide,
})?;
}
Ok(())
}
fn run_identity(router: &Router, layout: &Layout) -> Result<()> {
let exe = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "vissue".into());
emitln!("vissue {}", env!("CARGO_PKG_VERSION"));
emitln!("protocol: {}", vissue_core::org::PROTOCOL_VERSION);
emitln!("binary: {exe}");
emitln!("root: {}", layout.root().display());
emitln!("prefix: {}", layout.prefix());
emitln!("root={}", layout.root().display());
emitln!("prefix={}", layout.prefix());
if router.is_routed() {
for pref in router.visible_projects()? {
if pref.key == pref.dir
&& pref.layout.root() == layout.root()
&& pref.layout.prefix() == layout.prefix()
{
continue;
}
emitln!(
"route: {} -> {} {} {}",
pref.key,
pref.layout.root().display(),
pref.layout.prefix(),
pref.dir
);
}
}
Ok(())
}
fn run_digest(layout: &Layout, projects: &[String], json: bool, quiet: bool) -> Result<()> {
let digest = vissue_core::digest::corpus_digest(layout, projects)?;
if json {
emitln!("{}", serde_json::to_string_pretty(&digest.to_json())?);
} else if quiet {
emitln!("{}", digest.combined);
} else {
emit!("{}", digest.render());
}
Ok(())
}
fn run_show(router: &Router, id: &str, json: bool, org: bool) -> Result<()> {
let found = layout_for_id(router, id)?;
if json {
emitln!(
"{}",
serde_json::to_string_pretty(&agent::show_json(&found, id)?)?
);
} else if org {
emit!("{}", agent::org_text(&found, id)?);
} else {
emit!("{}", report::show(&found, id)?);
}
Ok(())
}
fn run_projects(router: &Router, json: bool) -> Result<()> {
let visible = router.visible_projects()?;
if json {
let names: Vec<&str> = visible.iter().map(|p| p.key.as_str()).collect();
emitln!("{}", serde_json::to_string_pretty(&names)?);
} else {
for project in visible {
emitln!("{}", project.key);
}
}
Ok(())
}
fn run_keys(check: bool, occupancy: bool) -> Result<()> {
let map = vissue_core::keys::KeyMap::load();
if check {
if let Some(err) = map.overlay_error {
eprintln!("error: {err}");
std::process::exit(1);
}
emitln!("ok");
} else if occupancy {
for (chord, id) in map.occupancy() {
emitln!("{chord}\t{id}");
}
} else {
if let Some(err) = &map.overlay_error {
eprintln!("error: {err}");
}
if let Some(leader) = map.leader {
emitln!("leader {leader}");
}
for line in map.table_lines() {
emitln!("{line}");
}
}
Ok(())
}
fn reads_the_corpus(command: &Command) -> bool {
!matches!(command, Command::Completions { .. } | Command::Man)
}
fn run() -> Result<()> {
let cli = Cli::parse();
let router = build_router(&cli)?;
if reads_the_corpus(&cli.command) && router.unique_layouts().len() == 1 {
router.default_layout().require_tracker()?;
}
let layout = router.default_layout().clone();
match cli.command {
Command::Create {
title,
project,
priority,
issue_type,
deadline,
scheduled,
tags,
parent,
quiet,
body,
body_file,
} => {
let project = ops::resolve_project(&layout, project.as_deref())?;
let body_text = match (body, body_file) {
(Some(b), _) => Some(b),
(None, Some(path)) => Some(read_body_file(&path)?),
(None, None) => None,
};
let out = create_routed(
&router,
&project,
&title,
CreateOpts {
priority,
issue_type: issue_type.as_deref(),
deadline: deadline.as_deref(),
scheduled: scheduled.as_deref(),
tags: tags.as_deref(),
parent: parent.as_deref(),
quiet,
body: body_text.as_deref(),
..Default::default()
},
)?;
emit!("{out}");
}
Command::Q {
title,
project,
issue_type,
parent,
} => {
let project = ops::resolve_project(&layout, project.as_deref())?;
let out = create_routed(
&router,
&project,
&title,
CreateOpts {
issue_type: issue_type.as_deref(),
parent: parent.as_deref(),
quiet: true,
..Default::default()
},
)?;
emit!("{out}");
}
Command::List {
project,
state,
json,
} => {
if json {
emitln!(
"{}",
serde_json::to_string_pretty(&issues_json_routed(
&router,
project.as_deref(),
state.as_deref(),
false
)?)?
);
} else {
emit!(
"{}",
list_routed(&router, project.as_deref(), state.as_deref(), false)?
);
}
}
Command::Show { id, json, org } => {
run_show(&router, &id, json, org)?;
}
Command::Update {
id,
state,
priority,
block,
unblock,
if_state,
if_gen,
} => {
let found = layout_for_id(&router, &id)?;
let outcome = ops::update_pred(
&found,
&id,
state.as_deref(),
priority,
block.as_deref(),
unblock.as_deref(),
UpdatePred {
if_state: if_state.as_deref(),
if_gen,
},
)?;
emit!("{}", outcome.report);
for hint in outcome.hints {
eprintln!("[hint] {hint}");
}
}
Command::Resolve { id, state } => {
let found = layout_for_id(&router, &id)?;
emit!("{}", ops::resolve_terminal(&found, &id, &state)?)
}
Command::Reject {
id,
to,
project,
title,
reason,
} => {
let found = layout_for_id(&router, &id)?;
let dst = reject_destination(&router, &found, to.as_deref(), project.as_deref())?;
emit!(
"{}",
ops::reject(
&found,
&id,
RejectOpts {
to: to.as_deref(),
project: dst.project.as_deref(),
title: title.as_deref(),
reason: reason.as_deref(),
dst_layout: Some(&dst.layout),
dst_extra_id_paths: &dst.extra_id_paths,
},
)?
)
}
Command::Ready { project, json } => {
if json {
emitln!(
"{}",
serde_json::to_string_pretty(&issues_json_routed(
&router,
project.as_deref(),
None,
true
)?)?
);
} else {
emit!("{}", list_routed(&router, project.as_deref(), None, true)?);
}
}
Command::Claim { id, force } => {
let found = layout_for_id(&router, &id)?;
emit!("{}", agent::claim(&found, &id, force)?)
}
Command::Deed { id, add, remove } => {
let found = layout_for_id(&router, &id)?;
emit!("{}", ops::deed(&found, &id, &add, &remove)?)
}
Command::Recall {
id,
depth,
deeds_only,
excerpts,
json,
} => {
let found = layout_for_id(&router, &id)?;
if deeds_only {
emit!("{}", report::recall_deeds(&found, &id, depth)?);
} else {
emit_shape(
json,
|| with_catalog(&found, |svc| svc.recall(&id, depth, excerpts)),
|| report::recall(&found, &id, depth, excerpts),
)?;
}
}
Command::Consensus {
id,
children,
gate,
json,
} => {
let found = layout_for_id(&router, &id)?;
let settled = if children {
let roll = vissue_core::consensus::of_plan(&found, &id)?;
emit_shape(
json,
|| vissue_core::Result::Ok(roll.clone()),
|| report::plan_consensus(&found, &id),
)?;
roll.settled()
} else {
let outcome = vissue_core::consensus::of_issue(&found, &id)?;
emit_shape(
json,
|| vissue_core::Result::Ok(outcome.clone()),
|| report::consensus(&found, &id),
)?;
outcome.settled()
};
if gate && !settled {
std::process::exit(1);
}
}
Command::Vote { id, choice } => {
let found = layout_for_id(&router, &id)?;
let who = vissue_core::config::identity(&found);
emit!("{}", ops::vote(&found, &id, choice.as_deref(), &who)?)
}
Command::Append { id, text, file } => {
let body = match (text, file) {
(Some(t), None) => t,
(None, Some(path)) => read_body_file(&path)?,
(None, None) => bail!("pass --text or --file (`-` reads stdin)"),
(Some(_), Some(_)) => unreachable!("clap rejects both"),
};
let found = layout_for_id(&router, &id)?;
emit!("{}", ops::append_body(&found, &id, &body)?)
}
Command::Note { id, text } => {
let found = layout_for_id(&router, &id)?;
emit!("{}", ops::note(&found, &id, &text.join(" "))?)
}
Command::Claims { by, project, json } => {
emit!(
"{}",
claims_routed(&router, by.as_deref(), project.as_deref(), json)?
)
}
Command::Fold { file, project } => {
let project = ops::resolve_project(&layout, project.as_deref())?;
let pref = router.route(&project);
emit!("{}", ops::fold(&pref.layout, &file, &pref.dir)?)
}
Command::Agenda {
days,
project,
json,
} => {
emit_shape(
json,
|| with_catalog(&layout, |svc| svc.agenda(days, project.as_deref())),
|| agenda_routed(&router, days, project.as_deref()),
)?;
}
Command::Hygiene { stale_days } => emit!("{}", hygiene_routed(&router, stale_days)?),
Command::Whoami => emitln!("{}", vissue_core::config::identity(&layout)),
Command::WaitingOn { id } => {
let found = layout_for_id(&router, &id)?;
emit!("{}", agent::waiting_on(&found, &id)?)
}
Command::BodyExcerpt { id, json } => {
let found = layout_for_id(&router, &id)?;
emit_shape(
json,
|| with_catalog(&found, |svc| svc.excerpt(&id)),
|| agent::body_excerpt(&found, &id),
)?;
}
Command::Search { query, limit, json } => {
emit_shape(
json,
|| with_catalog(&layout, |svc| svc.search(&query, limit)),
|| search_routed(&router, &query, limit),
)?;
}
Command::Children { id, json } => {
let found = layout_for_id(&router, &id)?;
emit_shape(
json,
|| with_catalog(&found, |svc| svc.children(&id)),
|| report::children(&found, &id),
)?;
}
Command::Ancestors { id, depth, json } => {
let found = layout_for_id(&router, &id)?;
emit_shape(
json,
|| with_catalog(&found, |svc| svc.ancestors(&id, depth)),
|| report::ancestors(&found, &id, depth),
)?;
}
Command::Impact { id, depth, json } => {
let found = layout_for_id(&router, &id)?;
emit_shape(
json,
|| with_catalog(&found, |svc| svc.impact(&id, depth)),
|| report::impact(&found, &id, depth),
)?;
}
Command::Related {
id,
depth,
limit,
format,
} => {
let found = layout_for_id(&router, &id)?;
emit!("{}", report::related(&found, &id, depth, limit, &format)?)
}
Command::Stale { days, project } => {
emit!("{}", stale_routed(&router, days, project.as_deref())?)
}
Command::Count {
project,
state,
ready,
} => emit!(
"{}",
count_routed(&router, project.as_deref(), state.as_deref(), ready)?
),
Command::Export { project } => emit!("{}", export_routed(&router, project.as_deref())?),
Command::Tree { id, format, json } => {
let found = layout_for_id(&router, &id)?;
emit_shape(
json,
|| with_catalog(&found, |svc| svc.tree(&id)),
|| report::tree(&found, &id, &format),
)?;
}
Command::Cycles => emit!("{}", cycles_routed(&router)?),
Command::Graph { project } => emit!("{}", graph_routed(&router, project.as_deref())?),
Command::Refile { id, to } => {
let found = layout_for_id(&router, &id)?;
let dest = router.route(&to);
emit!("{}", ops::refile_to(&found, &id, &dest.layout, &dest.dir)?)
}
Command::Backlinks { id, json } => emit_shape(
json,
|| backlinks_rows(&router, &id),
|| backlinks_text(&router, &id),
)?,
Command::Roadmap { project } => {
emit!("{}", roadmap_routed(&router, project.as_deref())?)
}
Command::Check => {
let (text, errors) = check_routed(&router)?;
emit!("{}", text);
if errors > 0 {
bail!("{errors} validation error(s)");
}
}
Command::Normalize { project, dry_run } => {
emit!(
"{}",
normalize_routed(&router, project.as_deref(), dry_run)?
)
}
Command::Digest {
projects,
json,
quiet,
} => {
run_digest(&layout, &projects, json, quiet)?;
}
Command::Mirror {
projects,
out,
check,
format,
state,
} => {
run_mirror(&layout, &projects, out, check, &format, state)?;
}
Command::Events { since, limit } => {
emit!("{}", events::since_report(&layout, since, limit)?)
}
Command::Ping { detail } => {
emit!("{}", events::ping_report(&layout, detail.as_deref())?)
}
Command::Wait {
last,
id,
until_terminal,
poll_ms,
timeout_ms,
} => {
run_wait(
&router,
&layout,
last,
id,
until_terminal,
poll_ms,
timeout_ms,
)?;
}
Command::Gen => emitln!("{}", events::generation(&layout)),
Command::Projects { json } => {
run_projects(&router, json)?;
}
Command::Surface => {
let mut cmd = Cli::command();
cmd.build();
let verbs: Vec<serde_json::Value> = cmd
.get_subcommands()
.map(|sub| {
let flags: Vec<&str> = sub
.get_arguments()
.filter_map(clap::Arg::get_long)
.collect();
serde_json::json!({
"name": sub.get_name(),
"hidden": sub.is_hide_set(),
"aliases": sub.get_all_aliases().collect::<Vec<_>>(),
"flags": flags,
})
})
.collect();
emitln!("{}", serde_json::to_string_pretty(&verbs)?);
}
Command::Completions { shell } => {
let mut cmd = Cli::command();
let name = cmd.get_name().to_string();
let mut buffer: Vec<u8> = Vec::new();
match shell {
CompletionShell::Bash => {
clap_complete::generate(clap_complete::Shell::Bash, &mut cmd, name, &mut buffer)
}
CompletionShell::Elvish => clap_complete::generate(
clap_complete::Shell::Elvish,
&mut cmd,
name,
&mut buffer,
),
CompletionShell::Fish => {
clap_complete::generate(clap_complete::Shell::Fish, &mut cmd, name, &mut buffer)
}
CompletionShell::Powershell => clap_complete::generate(
clap_complete::Shell::PowerShell,
&mut cmd,
name,
&mut buffer,
),
CompletionShell::Zsh => {
clap_complete::generate(clap_complete::Shell::Zsh, &mut cmd, name, &mut buffer)
}
}
emit!(
"{}",
strip_hidden_serve_flags(&String::from_utf8_lossy(&buffer))
);
}
Command::Keys { check, occupancy } => {
run_keys(check, occupancy)?;
}
Command::Man => {
let mut buffer: Vec<u8> = Vec::new();
clap_mangen::Man::new(Cli::command())
.render(&mut buffer)
.context("render the manual page")?;
emit!(
"{}",
trim_man_trailing_space(&String::from_utf8_lossy(&buffer))
);
}
Command::Identity => {
run_identity(&router, &layout)?;
}
Command::Serve(args) => {
let socket = args
.socket
.unwrap_or_else(vissue_control::default_socket_path);
let cfg = vissue_serve::ServeConfig {
layout,
socket,
exe: None,
};
let action = match args.action {
Some(ServeAction::Stop) => vissue_serve::Action::Stop,
Some(ServeAction::Restart) => vissue_serve::Action::Restart,
Some(ServeAction::Status { json }) => vissue_serve::Action::Status { json },
None if args.foreground => vissue_serve::Action::Foreground,
None if args.detach => vissue_serve::Action::Detach,
None => vissue_serve::Action::Foreground,
};
let code = vissue_serve::invoke(action, &cfg)?;
if code != 0 {
std::process::exit(code);
}
}
Command::Tui { offline, socket } => {
if !offline && cfg!(not(unix)) {
bail!("vissue tui is Unix-only");
}
let socket = socket.unwrap_or_else(vissue_control::default_socket_path);
let agent = vissue_core::config::identity(&layout);
vissue_tui::run(vissue_tui::RunOpts {
layout,
socket,
offline,
agent,
})?;
}
Command::Hud {
mode,
offline,
foreground,
toggle,
show,
hide,
iced,
rofi,
socket,
} => {
run_hud(
layout,
HudRequest {
mode,
offline,
foreground,
toggle,
show,
hide,
iced,
rofi,
socket,
},
)?;
}
}
std::io::stdout().flush()?;
Ok(())
}
fn trim_man_trailing_space(page: &str) -> String {
let mut out = String::with_capacity(page.len());
for line in page.lines() {
out.push_str(line.trim_end());
out.push('\n');
}
if page.ends_with('\n') || page.is_empty() {
out
} else {
out.pop();
out
}
}
fn strip_hidden_serve_flags(script: &str) -> String {
let mut out = String::with_capacity(script.len());
for line in script.lines() {
if line_is_hidden_serve_flag(line) {
continue;
}
let cleaned = line
.replace(" --foreground", "")
.replace("--foreground ", "")
.replace(" --no-detach", "")
.replace("--no-detach ", "");
out.push_str(&cleaned);
out.push('\n');
}
if script.ends_with('\n') || script.is_empty() {
out
} else {
out.pop();
out
}
}
fn line_is_hidden_serve_flag(line: &str) -> bool {
let trimmed = line.trim();
let names_flag = trimmed.contains("--foreground")
|| trimmed.contains("--no-detach")
|| trimmed.contains("-l foreground")
|| trimmed.contains("-l no-detach");
if !names_flag {
return false;
}
!trimmed.contains("opts=")
}
struct ExecHud {
layout: Layout,
socket: Option<PathBuf>,
offline: bool,
foreground: bool,
toggle: bool,
show: bool,
hide: bool,
}
const HUD_BIN_ENV: &str = "VISSUE_HUD_BIN";
fn resolve_hud_bin() -> Option<PathBuf> {
if let Ok(raw) = std::env::var(HUD_BIN_ENV) {
let t = raw.trim();
if !t.is_empty() {
return Some(PathBuf::from(t));
}
}
if let Ok(exe) = std::env::current_exe()
&& let Some(dir) = exe.parent()
{
let sibling = dir.join("vissue-hud");
if sibling.is_file() {
return Some(sibling);
}
#[cfg(windows)]
{
let exe = dir.join("vissue-hud.exe");
if exe.is_file() {
return Some(exe);
}
}
}
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let candidate = dir.join("vissue-hud");
if candidate.is_file() {
return Some(candidate);
}
#[cfg(windows)]
{
let exe = dir.join("vissue-hud.exe");
if exe.is_file() {
return Some(exe);
}
}
}
None
}
fn exec_hud(opts: ExecHud) -> Result<()> {
let Some(bin) = resolve_hud_bin().filter(|p| p.is_file()) else {
eprintln!("vissue-hud is not installed. Install it with:\n cargo install vissue-hud");
std::process::exit(127);
};
let mut cmd = std::process::Command::new(&bin);
cmd.arg("--root")
.arg(opts.layout.root())
.arg("--prefix")
.arg(opts.layout.prefix());
if let Some(socket) = opts.socket {
cmd.arg("--socket").arg(socket);
}
if opts.offline {
cmd.arg("--offline");
}
if opts.foreground {
cmd.arg("--foreground");
}
if opts.toggle {
cmd.arg("--toggle");
} else if opts.show {
cmd.arg("--show");
} else if opts.hide {
cmd.arg("--hide");
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let err = cmd.exec();
bail!("exec {}: {err}", bin.display());
}
#[cfg(not(unix))]
{
let status = cmd
.status()
.with_context(|| format!("spawn {}", bin.display()))?;
if let Some(code) = status.code() {
std::process::exit(code);
}
bail!("{} exited without a status", bin.display());
}
}
fn list_routed(
router: &Router,
project: Option<&str>,
state: Option<&str>,
ready_only: bool,
) -> Result<String> {
if let Some(p) = project {
let pref = router.route(p);
return if ready_only {
Ok(report::ready(&pref.layout, Some(&pref.dir))?)
} else {
Ok(report::list(&pref.layout, Some(&pref.dir), state, false)?)
};
}
let mut out = String::new();
for pref in router.visible_projects()? {
let chunk = if ready_only {
report::ready(&pref.layout, Some(&pref.dir))?
} else {
report::list(&pref.layout, Some(&pref.dir), state, false)?
};
out.push_str(&chunk);
}
Ok(out)
}
fn issues_json_routed(
router: &Router,
project: Option<&str>,
state: Option<&str>,
ready_only: bool,
) -> Result<serde_json::Value> {
if let Some(p) = project {
let pref = router.route(p);
return Ok(agent::issues_json(
&pref.layout,
Some(&pref.dir),
state,
ready_only,
)?);
}
let mut rows = Vec::new();
for pref in router.visible_projects()? {
let value = agent::issues_json(&pref.layout, Some(&pref.dir), state, ready_only)?;
if let Some(arr) = value.as_array() {
rows.extend(arr.iter().cloned());
}
}
Ok(serde_json::Value::Array(rows))
}
fn count_routed(
router: &Router,
project: Option<&str>,
state: Option<&str>,
ready: bool,
) -> Result<String> {
if let Some(p) = project {
let pref = router.route(p);
return Ok(report::count(&pref.layout, Some(&pref.dir), state, ready)?);
}
let mut n = 0usize;
for pref in router.visible_projects()? {
let text = report::count(&pref.layout, Some(&pref.dir), state, ready)?;
n += text.trim().parse::<usize>().unwrap_or(0);
}
Ok(format!("{n}\n"))
}
const NO_CLAIMS: &str = "no live claims\n";
const NOTHING_DATED: &str = "nothing dated in range\n";
fn claims_routed(
router: &Router,
by: Option<&str>,
project: Option<&str>,
json: bool,
) -> Result<String> {
if json {
return claims_json_routed(router, by, project);
}
concat_project_reports_with(router, project, Some(NO_CLAIMS), |layout, filter| {
report::claims(layout, by, filter, false)
})
}
fn claims_json_routed(router: &Router, by: Option<&str>, project: Option<&str>) -> Result<String> {
if let Some(p) = project {
let pref = router.route(p);
return Ok(report::claims(&pref.layout, by, Some(&pref.dir), true)?);
}
let mut rows: Vec<serde_json::Value> = Vec::new();
for pref in router.visible_projects()? {
let part = report::claims(&pref.layout, by, Some(&pref.dir), true)?;
let parsed: Vec<serde_json::Value> = serde_json::from_str(&part)
.with_context(|| format!("parsing the claims of {}", pref.dir))?;
rows.extend(parsed);
}
Ok(format!("{}\n", serde_json::to_string(&rows)?))
}
fn agenda_routed(router: &Router, days: i64, project: Option<&str>) -> Result<String> {
concat_project_reports_with(router, project, Some(NOTHING_DATED), |layout, filter| {
report::agenda(layout, days, filter)
})
}
fn stale_routed(router: &Router, days: i64, project: Option<&str>) -> Result<String> {
concat_project_reports(router, project, |layout, filter| {
report::stale(layout, days, filter)
})
}
fn export_routed(router: &Router, project: Option<&str>) -> Result<String> {
concat_project_reports(router, project, report::export)
}
fn graph_routed(router: &Router, project: Option<&str>) -> Result<String> {
if project.is_some() {
return concat_project_reports(router, project, report::graph);
}
let body = concat_project_reports(router, project, report::graph_body)?;
Ok(format!(
"{}{body}{}",
report::GRAPH_HEADER,
report::GRAPH_FOOTER
))
}
fn roadmap_routed(router: &Router, project: Option<&str>) -> Result<String> {
if project.is_some() {
return concat_project_reports(router, project, report::roadmap);
}
let body = concat_project_reports(router, project, report::roadmap_body)?;
Ok(format!("{}{body}", report::ROADMAP_HEADER))
}
fn concat_project_reports(
router: &Router,
project: Option<&str>,
f: impl FnMut(&Layout, Option<&str>) -> vissue_core::Result<String>,
) -> Result<String> {
concat_project_reports_with(router, project, None, f)
}
fn concat_project_reports_with(
router: &Router,
project: Option<&str>,
empty_marker: Option<&str>,
mut f: impl FnMut(&Layout, Option<&str>) -> vissue_core::Result<String>,
) -> Result<String> {
if let Some(p) = project {
let pref = router.route(p);
return Ok(f(&pref.layout, Some(&pref.dir))?);
}
let mut out = String::new();
for pref in router.visible_projects()? {
let part = f(&pref.layout, Some(&pref.dir))?;
match empty_marker {
Some(marker) if part == marker => {}
_ => out.push_str(&part),
}
}
if let Some(marker) = empty_marker
&& out.is_empty()
{
out.push_str(marker);
}
Ok(out)
}
fn search_routed(router: &Router, query: &str, limit: usize) -> Result<String> {
let mut out = String::new();
for layout in router.unique_layouts() {
out.push_str(&report::search(layout, query, limit)?);
}
Ok(out)
}
fn normalize_routed(router: &Router, project: Option<&str>, dry_run: bool) -> Result<String> {
let mut out = String::new();
if let Some(name) = project {
let pref = router.route(name);
out.push_str(&ops::normalize(&pref.layout, Some(&pref.dir), dry_run)?);
} else {
for layout in router.unique_layouts() {
out.push_str(&ops::normalize(layout, None, dry_run)?);
}
}
Ok(out)
}
fn hygiene_routed(router: &Router, stale_days: Option<i64>) -> Result<String> {
let mut out = String::new();
for layout in router.unique_layouts() {
out.push_str(&agent::hygiene(layout, stale_days)?);
}
Ok(out)
}
fn backlinks_layouts(router: &Router, id: &str) -> Result<Vec<Layout>> {
match layout_for_id(router, id) {
Ok(found) => Ok(vec![found]),
Err(err) => {
let unknown = matches!(
err.downcast_ref::<Error>(),
Some(Error::IssueNotFound { .. })
);
if unknown && ops::is_deed_accession(id) {
Ok(router.unique_layouts().into_iter().cloned().collect())
} else {
Err(err)
}
}
}
}
fn backlinks_rows(router: &Router, id: &str) -> Result<Vec<vissue_core::views::WalkHit>> {
let mut out = Vec::new();
for layout in backlinks_layouts(router, id)? {
out.extend(with_catalog(&layout, |svc| svc.backlinks(id))?);
}
Ok(out)
}
fn backlinks_text(router: &Router, id: &str) -> Result<String> {
let mut out = String::new();
for layout in backlinks_layouts(router, id)? {
out.push_str(&report::backlinks(&layout, id)?);
}
Ok(out)
}
fn cycles_routed(router: &Router) -> Result<String> {
let mut out = String::new();
for layout in router.unique_layouts() {
out.push_str(&report::cycles(layout)?);
}
Ok(out)
}
fn check_routed(router: &Router) -> Result<(String, usize)> {
let mut text = String::new();
let mut errors = 0usize;
for layout in router.unique_layouts() {
let report = report::check(layout)?;
text.push_str(&report.text);
errors += report.errors;
}
for (id, paths) in router.duplicate_ids()? {
errors += 1;
let listed = paths
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ");
text.push_str(&format!(
"[err] duplicate id across layouts: {id} in {listed}\n"
));
}
Ok((text, errors))
}
fn read_body_file(path: &str) -> Result<String> {
if path == "-" {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.context("read body from stdin")?;
return Ok(buf);
}
std::fs::read_to_string(path).with_context(|| format!("read body file {path}"))
}