use serde::Serialize;
use sparcli::{Cell, Column, Table};
use crate::cli::context::CliContext;
use crate::cli::output::{CliResult, Streams, info};
use crate::domain::repo::Repo;
use crate::service::repo_service::RepoService;
#[derive(Serialize)]
struct JsonEntry<'a> {
slug: Option<&'a str>,
name: String,
kind: &'a str,
path: String,
fav: bool,
archived: bool,
section: Option<&'a str>,
}
pub fn run(
service: &RepoService,
ctx: CliContext,
is_json: bool,
streams: &mut Streams,
) -> CliResult {
let repos = service.repos();
if is_json {
return print_json(repos, streams);
}
if repos.is_empty() {
info(
streams,
"No entries yet. Run hop to add some, or hop add <path>.",
);
return Ok(());
}
if ctx.is_output_tty {
streams.payload(&entry_table(repos))?;
return Ok(());
}
for repo in repos {
streams.line(&list_line(repo))?;
}
Ok(())
}
fn print_json(repos: &[Repo], streams: &mut Streams) -> CliResult {
let entries: Vec<JsonEntry> = repos.iter().map(json_entry).collect();
let text = serde_json::to_string_pretty(&entries).map_err(|error| {
crate::cli::output::CliError::runtime(error.to_string())
})?;
streams.line(&text)?;
Ok(())
}
fn json_entry(repo: &Repo) -> JsonEntry<'_> {
JsonEntry {
slug: repo.slug.as_deref(),
name: repo.display_name().to_string(),
kind: repo.kind.as_config_value(),
path: repo.path.display().to_string(),
fav: repo.fav,
archived: repo.archived,
section: repo.section.as_deref(),
}
}
fn entry_table(repos: &[Repo]) -> Table {
let mut table = Table::new().columns([
Column::new("Slug"),
Column::new("Name"),
Column::new("Kind"),
Column::new("Path"),
Column::new("Flags"),
]);
for repo in repos {
table = table.row([
Cell::new(repo.slug.clone().unwrap_or_default()),
Cell::new(repo.display_name().to_string()),
Cell::new(repo.kind.as_config_value().to_string()),
Cell::new(repo.path.display().to_string()),
Cell::new(entry_flags(repo)),
]);
}
table
}
fn entry_flags(repo: &Repo) -> String {
let mut flags = Vec::new();
if repo.fav {
flags.push("fav");
}
if repo.archived {
flags.push("archived");
}
flags.join(", ")
}
fn list_line(repo: &Repo) -> String {
let slug = repo
.slug
.as_deref()
.map(|s| format!("[{s}] "))
.unwrap_or_default();
let flags = entry_flags(repo);
let flags = if flags.is_empty() {
String::new()
} else {
format!(" ({flags})")
};
format!(
"{slug}{}\t{}\t{}{flags}",
repo.display_name(),
repo.kind.as_config_value(),
repo.path.display(),
)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
fn repo(name: &str, slug: Option<&str>) -> Repo {
let mut repo = Repo::new(PathBuf::from(format!("/code/{name}")));
repo.name = Some(name.to_string());
repo.slug = slug.map(str::to_string);
repo
}
fn streams<'a>(out: &'a mut Vec<u8>, err: &'a mut Vec<u8>) -> Streams<'a> {
Streams { out, err }
}
#[test]
fn list_line_includes_slug_kind_and_flags() {
let mut entry = repo("hop", Some("hop"));
entry.fav = true;
let line = list_line(&entry);
assert!(line.starts_with("[hop] hop\tgit\t/code/hop"));
assert!(line.ends_with("(fav)"));
}
#[test]
fn an_empty_store_keeps_stdout_clean_and_hints_on_stderr() {
let repos: Vec<Repo> = Vec::new();
let mut out = Vec::new();
let mut err = Vec::new();
let ctx = CliContext {
is_interactive: false,
is_output_tty: false,
};
let mut streams = streams(&mut out, &mut err);
if repos.is_empty() {
info(&mut streams, "No entries yet.");
}
assert!(out.is_empty(), "stdout must stay empty");
assert!(String::from_utf8(err).unwrap().contains("No entries yet"));
let _ = ctx;
}
#[test]
fn the_json_payload_carries_the_documented_fields() {
let entries = vec![repo("hop", Some("hop"))];
let mut out = Vec::new();
let mut err = Vec::new();
let mut s = streams(&mut out, &mut err);
print_json(&entries, &mut s).unwrap();
let text = String::from_utf8(out).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
let first = &parsed[0];
assert_eq!(first["slug"], "hop");
assert_eq!(first["name"], "hop");
assert_eq!(first["kind"], "git");
assert_eq!(first["path"], "/code/hop");
assert_eq!(first["fav"], false);
assert!(text.trim_start().starts_with('['));
assert!(err.is_empty());
}
#[test]
fn a_missing_slug_serialises_as_null_rather_than_an_empty_string() {
let entry = repo("notes", None);
let json = serde_json::to_value(json_entry(&entry)).unwrap();
assert!(json["slug"].is_null());
}
}