use std::io::Write;
use anyhow::Result;
use clap::ValueEnum;
use clap_complete::Shell;
use crate::board::{Board, Filter};
use crate::dates;
use crate::store::Store;
#[derive(Clone, Copy, ValueEnum)]
pub enum Kind {
Ids,
Statuses,
Buckets,
AtBuckets,
PlusTags,
Tags,
Due,
}
const FISH_DYNAMIC: &str = r#"
# --- dynamische Vorschläge (shdo) ---
# shdo nimmt nur bei --dir Pfade entgegen, sonst nie Dateinamen vorschlagen
complete -c shdo -f
complete -c shdo -l dir -x -a '(__fish_complete_directories (commandline -ct))'
function __shdo_nargs --description 'Positional arguments including the command, without --dir'
set -l toks (commandline -opc)
set -e toks[1]
set -l n 0
set -l skip 0
for t in $toks
if test $skip -eq 1
set skip 0
continue
end
switch $t
case --dir
set skip 1
case '-*'
case '*'
set n (math $n + 1)
end
end
echo $n
end
function __shdo_arg --description '__shdo_arg <pos> <command…>: cursor is on argument <pos>'
set -l pos $argv[1]
set -e argv[1]
__fish_seen_subcommand_from $argv; and test (__shdo_nargs) -eq $pos
end
function __shdo_complete --description 'shdo __complete using the --dir from the command line'
set -l toks (commandline -opc)
set -l dir
if set -l i (contains -i -- --dir $toks); and set -q toks[(math $i + 1)]
set dir --dir $toks[(math $i + 1)]
end
shdo $dir __complete $argv 2>/dev/null
end
complete -c shdo -n '__shdo_arg 1 done rm edit mv bucket tag' -f -a '(__shdo_complete ids)'
complete -c shdo -n '__shdo_arg 2 mv' -f -a '(__shdo_complete statuses)'
complete -c shdo -n '__shdo_arg 2 bucket' -f -a '(__shdo_complete buckets)'
complete -c shdo -n '__shdo_arg 1 ls' -f -a '(__shdo_complete at-buckets)'
complete -c shdo -n 'string match -q -- "@*" (commandline -ct)' -f -a '(__shdo_complete at-buckets)'
complete -c shdo -n 'string match -q -- "due:*" (commandline -ct)' -f -a '(__shdo_complete due)'
complete -c shdo -n 'string match -q -- "+*" (commandline -ct)' -f -a '(__shdo_complete plus-tags)'
complete -c shdo -n '__fish_seen_subcommand_from tag; and test (__shdo_nargs) -ge 2' -f -a '(__shdo_complete tags)'
"#;
pub const SETUP_HELP: &str = "\
Setup:
fish shdo completions fish > ~/.config/fish/completions/shdo.fish
bash shdo completions bash > ~/.local/share/bash-completion/completions/shdo
zsh shdo completions zsh > ~/.zfunc/_shdo
(in ~/.zshrc before compinit: fpath+=~/.zfunc)
Then open a new shell. Run the command again after updating shdo so new
commands and options are included.
Fish additionally gets dynamic suggestions: task IDs with titles,
statuses, buckets (@…), tags (+…) and due: values with dates. Bash, Zsh, Elvish
and PowerShell complete commands and options.";
pub fn setup_guide() -> String {
let mut out = format!(
"Prints a completion script for your shell.\n\n\
Usage: shdo completions <SHELL>\n\
Shells: bash, elvish, fish, powershell, zsh\n\n{SETUP_HELP}\n"
);
let own = std::env::var("SHELL")
.ok()
.and_then(|s| s.rsplit('/').next().map(str::to_string))
.and_then(|name| Shell::from_str(&name, true).ok());
if let Some(shell) = own {
out.push_str(&format!(
"\nYour shell according to $SHELL: {shell} → shdo completions {shell}\n"
));
}
out
}
pub fn print_script(shell: Shell, cmd: &mut clap::Command) {
let mut buf = Vec::new();
clap_complete::generate(shell, cmd, "shdo", &mut buf);
let mut out = std::io::stdout();
if shell != Shell::Fish {
let _ = out.write_all(&buf);
return;
}
let script = String::from_utf8_lossy(&buf);
for line in script.lines().filter(|l| !l.contains("__complete")) {
let _ = writeln!(out, "{line}");
}
let _ = out.write_all(FISH_DYNAMIC.as_bytes());
let shells: Vec<String> = Shell::value_variants()
.iter()
.filter_map(|s| s.to_possible_value().map(|v| v.get_name().to_string()))
.collect();
let _ = writeln!(
out,
"complete -c shdo -n '__shdo_arg 1 completions' -f -a '{}'",
shells.join(" ")
);
}
pub fn candidates(store: &Store, kind: Kind) -> Result<Vec<String>> {
let lines = match kind {
Kind::Ids => {
let board = Board::load(store, &Filter::all())?;
board
.columns
.iter()
.flat_map(|c| {
c.tasks
.iter()
.map(move |t| format!("{}\t{} ({})", t.id, t.title, c.def.name))
})
.collect()
}
Kind::Statuses => store
.board_config()?
.statuses
.iter()
.map(|s| format!("{}\t{}", s.key, s.name))
.collect(),
Kind::Buckets => {
let mut v: Vec<String> = store
.board_config()?
.buckets
.iter()
.map(|b| format!("{}\t{}", b.key, b.name))
.collect();
v.push("-\tno bucket".into());
v
}
Kind::AtBuckets => store
.board_config()?
.buckets
.iter()
.map(|b| format!("@{}\t{}", b.key, b.name))
.collect(),
Kind::PlusTags | Kind::Tags => {
let board = Board::load(store, &Filter::all())?;
let plus = if matches!(kind, Kind::PlusTags) {
"+"
} else {
""
};
board
.tag_counts
.iter()
.map(|(t, n)| format!("{plus}{t}\t{n} open"))
.collect()
}
Kind::Due => due_candidates(dates::today()),
};
Ok(lines)
}
fn due_candidates(today: jiff::civil::Date) -> Vec<String> {
[
"today", "tomorrow", "mon", "tue", "wed", "thu", "fri", "sat", "sun", "+1w", "+2w", "+1m",
]
.iter()
.filter_map(|s| {
let d = dates::parse_date(s, today).ok()?;
Some(format!("due:{s}\t{}", dates::fmt_long(d)))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use jiff::civil::date;
#[test]
fn due_candidates_resolve_dates() {
let c = due_candidates(date(2026, 10, 22));
assert!(c.contains(&"due:fri\tFRI 10/23/2026".to_string()), "{c:?}");
assert!(c.iter().all(|l| l.starts_with("due:") && l.contains('\t')));
}
#[test]
fn candidates_from_store() {
let tmp = tempfile::tempdir().unwrap();
let store = Store::new(tmp.path().to_path_buf());
store.init(crate::i18n::Lang::De).unwrap();
let t = store.create("Rechnung", "todo", &jiff::Zoned::now());
t.save().unwrap();
let ids = candidates(&store, Kind::Ids).unwrap();
assert_eq!(ids, [format!("{}\tRechnung (Offen)", t.id)]);
let st = candidates(&store, Kind::Statuses).unwrap();
assert_eq!(st[0], "todo\tOffen");
assert_eq!(candidates(&store, Kind::Buckets).unwrap(), ["-\tno bucket"]);
assert!(candidates(&store, Kind::AtBuckets).unwrap().is_empty());
}
}