#![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::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,
},
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>,
},
Hygiene {
#[arg(long)]
stale_days: Option<i64>,
},
Whoami,
#[command(name = "waiting-on")]
WaitingOn { id: String },
#[command(name = "body-excerpt")]
BodyExcerpt { id: String },
Search {
query: String,
#[arg(short = 'n', long, default_value = "20")]
limit: usize,
},
Children { id: String },
Ancestors {
id: String,
#[arg(short, long, default_value = "3")]
depth: usize,
},
Impact {
id: String,
#[arg(short, long, default_value = "3")]
depth: usize,
},
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,
},
Cycles,
Graph {
#[arg(short = 'p', short_alias = 'P', long)]
project: Option<String>,
},
Refile {
id: String,
#[arg(long)]
to: String,
},
Backlinks { id: String },
Roadmap {
#[arg(short = 'p', short_alias = 'P', long)]
project: Option<String>,
},
Check,
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,
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,
}
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())?;
if cli.no_route {
return Ok(Router::unrouted(default));
}
Ok(Router::load(default)?)
}
fn create_routed(
router: &Router,
project: &str,
title: &str,
opts: CreateOpts<'_>,
) -> Result<String> {
let pref = router.route(project);
let extra = router.extra_ids_for(&pref.dir)?;
let opts = CreateOpts {
extra_ids: &extra,
..opts
};
Ok(ops::create(&pref.layout, &pref.dir, title, opts)?)
}
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_ids: Vec<String>,
}
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_ids: Vec::new(),
});
}
let Some(project) = project else {
return Ok(RejectDest {
layout: src.clone(),
project: None,
extra_ids: Vec::new(),
});
};
let pref = router.route(project);
let extra_ids = router.extra_ids_for(&pref.dir)?;
Ok(RejectDest {
layout: pref.layout,
project: Some(pref.dir),
extra_ids,
})
}
fn run() -> Result<()> {
let cli = Cli::parse();
let router = build_router(&cli)?;
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 } => {
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)?);
}
}
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_ids: &dst.extra_ids,
},
)?
)
}
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::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 } => {
emit!("{}", 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 } => {
let found = layout_for_id(&router, &id)?;
emit!("{}", agent::body_excerpt(&found, &id)?)
}
Command::Search { query, limit } => {
emit!("{}", search_routed(&router, &query, limit)?)
}
Command::Children { id } => {
let found = layout_for_id(&router, &id)?;
emit!("{}", report::children(&found, &id)?)
}
Command::Ancestors { id, depth } => {
let found = layout_for_id(&router, &id)?;
emit!("{}", report::ancestors(&found, &id, depth)?)
}
Command::Impact { id, depth } => {
let found = layout_for_id(&router, &id)?;
emit!("{}", 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 } => {
let found = layout_for_id(&router, &id)?;
emit!("{}", 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 } => {
let found = layout_for_id(&router, &id)?;
emit!("{}", report::backlinks(&found, &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::Digest {
projects,
json,
quiet,
} => {
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());
}
}
Command::Mirror {
projects,
out,
check,
format,
state,
} => {
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());
}
}
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,
} => {
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);
}
}
}
Command::Gen => emitln!("{}", events::generation(&layout)),
Command::Projects => {
for project in router.visible_projects()? {
emitln!("{}", project.key);
}
}
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 } => {
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}");
}
}
}
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 => {
let exe = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "vissue".into());
emitln!("vissue {}", env!("CARGO_PKG_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
);
}
}
}
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,
} => {
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,
})?;
}
}
}
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"))
}
fn claims_routed(
router: &Router,
by: Option<&str>,
project: Option<&str>,
json: bool,
) -> Result<String> {
concat_project_reports(router, project, |layout, filter| {
report::claims(layout, by, filter, json)
})
}
fn agenda_routed(router: &Router, days: i64, project: Option<&str>) -> Result<String> {
concat_project_reports(router, project, |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> {
concat_project_reports(router, project, report::graph)
}
fn roadmap_routed(router: &Router, project: Option<&str>) -> Result<String> {
concat_project_reports(router, project, report::roadmap)
}
fn concat_project_reports(
router: &Router,
project: 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()? {
out.push_str(&f(&pref.layout, Some(&pref.dir))?);
}
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 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 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}"))
}