use anyhow::Result;
use owo_colors::OwoColorize;
use teravars::Engine;
use crate::cli::ListArgs;
use crate::commands::ShokaContext;
use crate::config::{ResolvedConfig, ShokaConfig};
use crate::state::{Repo, Shelf};
pub async fn run(ctx: &ShokaContext, args: ListArgs) -> Result<()> {
let cfg = ShokaConfig::load(&ctx.paths)?;
let resolved = cfg.resolve(ctx.profile_override.as_deref())?;
let shelf = Shelf::load(&ctx.paths)?;
if shelf.is_empty() {
println!(
"{} (no repos yet — `shoka clone <url>` to add one)",
"0 repos".dimmed()
);
return Ok(());
}
let mut engine = Engine::new();
let filtered = filter_repos(&shelf, &resolved, &args, &mut engine)?;
if filtered.is_empty() {
println!(
"{} matched the filters ({} on the shelf total)",
"0 repos".dimmed(),
shelf.len()
);
return Ok(());
}
for repo in &filtered {
print_repo(repo, &resolved, &mut engine)?;
}
if filtered.len() < shelf.len() {
println!();
println!(
"{} {} of {} repos matched",
"→".dimmed(),
filtered.len(),
shelf.len()
);
}
Ok(())
}
fn filter_repos<'s>(
shelf: &'s Shelf,
resolved: &ResolvedConfig,
args: &ListArgs,
engine: &mut Engine,
) -> Result<Vec<&'s Repo>> {
let mut out = Vec::with_capacity(shelf.len());
for repo in &shelf.repos {
if !args.tags.is_empty() && !has_all_tags(repo, &args.tags) {
continue;
}
if args.has_agents {
let path = resolved.clone_path_for(repo, engine)?;
if !path.join("AGENTS.md").exists() {
continue;
}
}
out.push(repo);
}
Ok(out)
}
fn has_all_tags(repo: &Repo, wanted: &[String]) -> bool {
wanted.iter().all(|w| repo.tags.iter().any(|t| t == w))
}
fn print_repo(repo: &Repo, resolved: &ResolvedConfig, engine: &mut Engine) -> Result<()> {
println!("{}", repo.slug().bold());
let path = resolved.clone_path_for(repo, engine)?;
println!(" {} {}", "path :".dimmed(), path.display());
if !repo.tags.is_empty() {
println!(" {} {}", "tags :".dimmed(), repo.tags.join(" "));
}
if let Some(vcs) = repo.vcs {
println!(" {} {vcs:?}", "vcs :".dimmed());
}
if let Some(note) = &repo.note {
println!(" {} {}", "note :".dimmed(), note);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{GlobalConfig, ShokaConfig, VcsDefault};
use std::collections::BTreeMap;
fn resolved_default_layout() -> ResolvedConfig {
ShokaConfig {
global: GlobalConfig {
root: Some("/r".into()),
..Default::default()
},
routes: vec![],
profiles: BTreeMap::new(),
}
.resolve(None)
.expect("resolve")
}
#[test]
fn clone_path_for_default_layout_is_ghq_shaped() {
let r = resolved_default_layout();
let repo = Repo::new("github.com", "yukimemi", "shoka");
let p = r.clone_path_for_one(&repo).unwrap();
assert!(p.is_absolute(), "expected absolute, got {p:?}");
let s = p.to_string_lossy().replace('\\', "/");
assert!(
s.ends_with("/r/github.com/yukimemi/shoka"),
"rendered path should follow the ghq layout, got {s:?}"
);
}
#[test]
fn clone_path_for_honors_route_layout() {
let cfg = ShokaConfig {
global: GlobalConfig {
root: Some("/r".into()),
..Default::default()
},
routes: vec![crate::config::Route {
pattern: "host:github.com".into(),
root: None,
layout: Some("{{ root }}/{{ owner }}-{{ name }}".into()),
default_vcs: None,
default_protocol: None,
}],
profiles: BTreeMap::new(),
};
let resolved = cfg.resolve(None).expect("resolve");
let p = resolved
.clone_path_for_one(&Repo::new("github.com", "foo", "bar"))
.unwrap();
let s = p.to_string_lossy().replace('\\', "/");
assert!(
s.ends_with("/r/foo-bar"),
"flat layout from route should win, got {s:?}"
);
}
#[test]
fn clone_path_for_per_repo_vcs_wins_over_default() {
let r = resolved_default_layout();
let mut repo = Repo::new("github.com", "u", "n");
repo.vcs = Some(VcsDefault::Jj);
let p = r.clone_path_for_one(&repo).unwrap();
assert!(p.is_absolute());
}
#[test]
fn has_all_tags_is_and_semantics() {
let mut repo = Repo::new("github.com", "u", "n");
repo.tags = vec!["rust".into(), "cli".into()];
let both: Vec<String> = vec!["rust".into(), "cli".into()];
assert!(has_all_tags(&repo, &both));
let extra: Vec<String> = vec!["rust".into(), "cli".into(), "tui".into()];
assert!(!has_all_tags(&repo, &extra));
let one: Vec<String> = vec!["rust".into()];
assert!(has_all_tags(&repo, &one));
let none: Vec<String> = vec![];
assert!(has_all_tags(&repo, &none));
}
#[test]
fn filter_repos_applies_tag_filter() {
let mut shelf = Shelf::default();
let mut a = Repo::new("github.com", "u", "rust-repo");
a.tags = vec!["rust".into()];
let mut b = Repo::new("github.com", "u", "go-repo");
b.tags = vec!["go".into()];
let mut c = Repo::new("github.com", "u", "both-repo");
c.tags = vec!["rust".into(), "cli".into()];
shelf.add(a).unwrap();
shelf.add(b).unwrap();
shelf.add(c).unwrap();
let r = resolved_default_layout();
let mut engine = Engine::new();
let filtered = filter_repos(
&shelf,
&r,
&ListArgs {
tags: vec!["rust".into()],
has_agents: false,
},
&mut engine,
)
.unwrap();
assert_eq!(filtered.len(), 2);
assert!(filtered.iter().any(|r| r.name == "rust-repo"));
assert!(filtered.iter().any(|r| r.name == "both-repo"));
let filtered = filter_repos(
&shelf,
&r,
&ListArgs {
tags: vec!["rust".into(), "cli".into()],
has_agents: false,
},
&mut engine,
)
.unwrap();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name, "both-repo");
}
#[test]
fn filter_repos_has_agents_walks_filesystem() {
use std::fs;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let root_str = tmp.path().to_string_lossy().to_string();
let cfg = ShokaConfig {
global: GlobalConfig {
root: Some(root_str),
layout: "{{ root }}/{{ name }}".into(), ..Default::default()
},
routes: vec![],
profiles: BTreeMap::new(),
};
let resolved = cfg.resolve(None).unwrap();
fs::create_dir_all(tmp.path().join("with-agents")).unwrap();
fs::write(tmp.path().join("with-agents/AGENTS.md"), "# stub").unwrap();
fs::create_dir_all(tmp.path().join("plain")).unwrap();
let mut shelf = Shelf::default();
shelf
.add(Repo::new("github.com", "u", "with-agents"))
.unwrap();
shelf.add(Repo::new("github.com", "u", "plain")).unwrap();
let mut engine = Engine::new();
let filtered = filter_repos(
&shelf,
&resolved,
&ListArgs {
tags: vec![],
has_agents: true,
},
&mut engine,
)
.unwrap();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name, "with-agents");
}
}