use std::io::IsTerminal;
use anyhow::Result;
use inquire::{InquireError, Select};
use crate::catalog::artifacts::{self, Artifact};
use crate::catalog::place_template::PLACE_TEMPLATE;
use crate::catalog::tool_catalog;
use crate::catalog::tool_settings;
use crate::catalog::tool_usage::{self, Usage};
use crate::catalog::wally_packages;
use crate::config::SavedSetup;
use crate::ui;
pub fn run(key: Option<&str>) -> Result<()> {
match key {
Some(key) => show_one(key),
None if std::io::stdin().is_terminal() => browse(),
None => list_all(),
}
}
struct Row {
key: String,
description: String,
badge: String,
}
enum Section {
Entries { label: &'static str, rows: Vec<Row> },
Page { label: &'static str, render: fn() },
}
impl Section {
fn label(&self) -> &'static str {
match self {
Section::Entries { label, .. } | Section::Page { label, .. } => label,
}
}
}
fn sections() -> Vec<Section> {
let mut sections = vec![
Section::Entries {
label: "Packages - libraries a project depends on",
rows: wally_packages::PACKAGES
.iter()
.map(|p| Row {
key: p.key.to_string(),
description: p.description.to_string(),
badge: p.category.label().to_string(),
})
.collect(),
},
Section::Entries {
label: "Tools, apps, plugins & extensions - what gets installed",
rows: tool_catalog::FAMILY_ORDER
.iter()
.flat_map(|family| tool_catalog::in_family(family))
.map(|t| Row {
key: t.key.to_string(),
description: t.description.to_string(),
badge: t.kind.label().to_string(),
})
.collect(),
},
Section::Entries {
label: "Generated files - what `rproj new` can write, and why",
rows: artifacts::ARTIFACTS
.iter()
.map(|a| Row {
key: a.key.to_string(),
description: a.description.to_string(),
badge: a.category.label().to_string(),
})
.collect(),
},
Section::Entries {
label: "Topics - how the pieces fit together",
rows: tool_usage::TOPICS
.iter()
.map(|t| Row {
key: t.key.to_string(),
description: first_sentence(t.what).to_string(),
badge: "topic".to_string(),
})
.collect(),
},
Section::Entries {
label: "Configurable - settings rproj can edit for you",
rows: tool_settings::CONFIGURABLE_TOOLS
.iter()
.map(|t| Row {
key: t.key.to_string(),
description: format!("{} - edit with `rproj configure {}`", t.display_name, t.key),
badge: "configure".to_string(),
})
.collect(),
},
Section::Page {
label: "Place template - the instance tree every project starts with",
render: print_place_template,
},
];
if !SavedSetup::list().is_empty() {
sections.push(Section::Page {
label: "Saved setups - package compositions you saved",
render: print_saved_setups,
});
}
sections
}
fn browse() -> Result<()> {
let sections = sections();
println!("The rproj catalog. Pick a section, then type to filter.\n");
loop {
let labels: Vec<String> = sections.iter().map(|s| s.label().to_string()).collect();
let Some(picked) = ask("What do you want to look up?", labels, false)? else {
return Ok(());
};
let Some(section) = sections.iter().find(|s| s.label() == picked) else {
return Ok(());
};
match section {
Section::Page { render, .. } => {
println!();
render();
println!();
}
Section::Entries { rows, label } => browse_entries(label, rows)?,
}
}
}
fn browse_entries(label: &str, rows: &[Row]) -> Result<()> {
const BACK: &str = "← back to the sections";
loop {
let mut options: Vec<String> = rows
.iter()
.map(|r| ui::option_line(&r.key, &r.description, &r.badge))
.collect();
options.push(BACK.to_string());
let Some(picked) = ask(label, options, true)? else {
return Ok(());
};
if picked == BACK {
return Ok(());
}
println!();
show_one(ui::option_key(&picked))?;
println!();
}
}
fn ask(prompt: &str, options: Vec<String>, compact_echo: bool) -> Result<Option<String>> {
let mut select = Select::new(prompt, options).with_page_size(ui::page_size());
if compact_echo {
select = select.with_formatter(&ui::compact_select_answer);
}
match select.prompt() {
Ok(choice) => Ok(Some(choice)),
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(None),
Err(err) => Err(err.into()),
}
}
fn show_one(key: &str) -> Result<()> {
if let Some(pkg) = wally_packages::find(key) {
heading(pkg.key);
println!("category: {}", pkg.category.label());
println!("status: {}", pkg.maintenance.badge());
println!("source: {}", pkg.source);
println!("docs: {}", pkg.docs_url);
println!();
println!("{}", pkg.description);
if let Some(usage) = tool_usage::find(key) {
print_usage(usage);
}
return Ok(());
}
if let Some(tool) = tool_catalog::find(key) {
heading(tool.key);
println!("family: {}", tool.family);
println!("kind: {}", tool.kind.label());
println!("provider: {}", tool.kind.provider());
println!("status: {}", tool.maintenance.badge());
println!("docs: {}", tool.docs_url);
println!();
println!("{}", tool.description);
if let Some(usage) = tool_usage::find(key) {
print_usage(usage);
}
if tool_settings::find(key).is_some() {
println!("\nConfigure: rproj configure {key}");
}
return Ok(());
}
if let Some(artifact) = artifacts::find(key) {
print_artifact(artifact);
return Ok(());
}
if let Some(usage) = tool_usage::find(key) {
heading(usage.key);
print_usage(usage);
return Ok(());
}
println!("No catalog entry named `{key}`. Run `rproj info` with no argument to browse.");
Ok(())
}
fn heading(title: &str) {
println!("{title}");
println!("{}", "-".repeat(title.chars().count()));
}
fn always_settled(artifact: &Artifact) -> bool {
artifact
.entailed_by
.iter()
.any(|e| artifact.requires.contains(&e.when))
}
fn asked_line(artifact: &Artifact) -> String {
if artifact.mandatory {
return "never - every project gets it, or it isn't a Rojo project".to_string();
}
if always_settled(artifact) {
return "never - whenever it applies at all, it is already settled (below)".to_string();
}
if artifact.entailed_by.is_empty() {
return format!(
"yes, in `rproj new` ({} by default)",
if artifact.default_selected { "on" } else { "off" }
);
}
"sometimes - unless an earlier answer settles it (below)".to_string()
}
fn print_artifact(artifact: &Artifact) {
heading(artifact.key);
println!("category: {}", artifact.category.label());
println!("asked: {}", asked_line(artifact));
if !artifact.requires.is_empty() {
let needs: Vec<String> = artifact.requires.iter().map(|r| r.describe()).collect();
println!("needs: {}", needs.join(", and "));
}
println!();
println!("{}", artifact.description);
if !artifact.entailed_by.is_empty() {
println!("\nSettled, not asked, when you have:");
for entailment in artifact.entailed_by {
println!(" {}", entailment.when.describe());
println!(" {}", entailment.because);
}
println!(
"\nDeclining it would contradict that answer, so the way to not have\n\
this file is to change the answer above."
);
}
}
fn print_usage(usage: &Usage) {
println!("\n{}", usage.what);
println!("\nWhen: {}", usage.when);
if !usage.commands.is_empty() {
println!("\nCommands:");
let width = usage.commands.iter().map(|(c, _)| c.len()).max().unwrap_or(0);
for (cmd, explanation) in usage.commands {
println!(" {cmd:<width$} {explanation}");
}
}
if !usage.notes.is_empty() {
println!("\nWorth knowing:");
for note in usage.notes {
println!(" - {note}");
}
}
}
fn list_all() -> Result<()> {
println!("WALLY PACKAGES");
for category in wally_packages::Category::ALL {
println!(" {}", category.label());
for pkg in wally_packages::in_category(category) {
println!(" {:<18} {:<18} {}", pkg.key, pkg.author(), pkg.version());
}
}
println!("\nTOOLS");
for &family in tool_catalog::FAMILY_ORDER {
let mut entries = tool_catalog::in_family(family).peekable();
if entries.peek().is_none() {
continue;
}
println!(" {family}");
for tool in entries {
println!(" {:<18} {:<32} ({})", tool.key, tool.kind.provider(), tool.kind.label());
}
}
println!("\nGENERATED FILES (rproj info <key> for why a project gets one)");
for artifact in artifacts::ARTIFACTS {
let when = if artifact.mandatory {
"always"
} else if always_settled(artifact) {
"always, when it applies"
} else if artifact.entailed_by.is_empty() {
"optional"
} else {
"optional, or settled by an earlier answer"
};
println!(" {:<24} {when}", artifact.key);
}
println!("\nCONFIGURABLE (rproj configure <key>)");
for tool in tool_settings::CONFIGURABLE_TOOLS {
println!(" {:<18} {}", tool.key, tool.display_name);
}
println!("\nPLACE TEMPLATE (applied to every new project's default.project.json)");
print_place_template();
let setups = SavedSetup::list();
if !setups.is_empty() {
println!("\nSAVED SETUPS (rproj new <name> --like <setup>)");
print_saved_setups();
}
println!("\nTOPICS");
for topic in tool_usage::TOPICS {
println!(" {:<18} {}", topic.key, first_sentence(topic.what));
}
println!("\nRun `rproj info <key>` for what it does, the commands to use it, and the gotchas.");
Ok(())
}
fn print_place_template() {
for spec in PLACE_TEMPLATE {
let location = match spec.parent {
Some(parent) => format!("{parent}.{}", spec.name),
None => spec.name.to_string(),
};
println!(" {location} ({})", spec.class_name);
for prop in spec.properties {
println!(" {:<26} {}", prop.name, prop.value.display());
}
}
}
fn print_saved_setups() {
for setup in SavedSetup::list() {
println!(" {setup}");
}
}
fn first_sentence(text: &str) -> &str {
match text.find(". ") {
Some(i) => &text[..=i],
None => text,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_section_is_empty() {
for section in sections() {
if let Section::Entries { label, rows } = §ion {
assert!(!rows.is_empty(), "{label} has no entries");
}
}
}
#[test]
fn every_row_resolves_to_a_detail_page() {
for section in sections() {
if let Section::Entries { label, rows } = §ion {
for row in rows {
let found = wally_packages::find(&row.key).is_some()
|| tool_catalog::find(&row.key).is_some()
|| artifacts::find(&row.key).is_some()
|| tool_usage::find(&row.key).is_some();
assert!(found, "{label}: `{}` has no detail page", row.key);
}
}
}
}
#[test]
fn every_row_survives_rendering_as_a_picker_option() {
for section in sections() {
if let Section::Entries { rows, .. } = §ion {
for row in rows {
let line = ui::option_line(&row.key, &row.description, &row.badge);
assert_eq!(ui::option_key(&line), row.key, "{line}");
}
}
}
}
#[test]
fn the_back_option_cannot_collide_with_an_entry() {
for section in sections() {
if let Section::Entries { rows, .. } = §ion {
for row in rows {
assert_ne!(
ui::option_line(&row.key, &row.description, &row.badge),
"← back to the sections"
);
}
}
}
}
#[test]
fn the_asked_line_separates_never_from_sometimes() {
let line = |key: &str| asked_line(artifacts::find(key).expect(key));
assert!(line("src").starts_with("never -"), "{}", line("src"));
assert_eq!(
line("selene.toml"),
"never - whenever it applies at all, it is already settled (below)"
);
assert!(line("wally.toml").starts_with("sometimes -"), "{}", line("wally.toml"));
assert_eq!(line("stylua.toml"), "yes, in `rproj new` (on by default)");
assert_eq!(
line(".github/workflows/ci.yml"),
"yes, in `rproj new` (off by default)"
);
}
#[test]
fn section_labels_are_unique() {
let mut labels: Vec<&str> = sections().iter().map(|s| s.label()).collect();
let before = labels.len();
labels.sort_unstable();
labels.dedup();
assert_eq!(before, labels.len(), "duplicate section label");
}
}