use standout_render::{render_with_output, OutputMode, Renderer, Theme};
use crate::Result;
const THEME_YAML: &str = r#"
pack-name:
bold: true
fg: blue
filename:
fg: white
handler-symbol:
bold: true
fg: yellow
description:
dim: true
deployed:
fg: green
pending:
fg: magenta
error:
fg: red
bold: true
broken:
fg: red
stale:
fg: yellow
warning:
fg: yellow
message:
fg: cyan
dim:
dim: true
header:
bold: true
dry-run:
fg: yellow
italic: true
conflict-banner:
fg: white
bg: red
bold: true
conflict-header:
fg: white
bg: red
bold: true
conflict-target:
fg: red
bold: true
conflict-pack:
fg: red
conflict-hint:
dim: true
ignored-pack:
dim: true
italic: true
group-banner-deployed:
fg: green
bold: true
group-banner-pending:
fg: yellow
bold: true
group-banner-error:
fg: red
bold: true
group-banner-ignored:
dim: true
bold: true
# Tutorial prompt question text. The interactive `dodot tutorial`
# uses inquire for the prompt UI; this style is mirrored by hand into
# its `RenderConfig` (see `tutorial.rs::tutorial_render_config`). Keep
# attributes here in sync with that function so users have one place
# to change the look.
tutorial-prompt:
italic: true
# CLI help tags. The hand-written --help text in `dodot-cli/src/help/`
# uses these alongside the semantic tags above. Mirror standout's
# default help theme so the look matches the rest of dodot's output:
# item — bold (command names, option flags)
# desc — plain (descriptions next to items)
# usage — plain (the usage line)
# example — plain (example blocks)
# about — plain (intro / about text)
item:
bold: true
desc: {}
usage: {}
example: {}
about: {}
"#;
pub const TEMPLATE_PACK_STATUS: &str = include_str!("../templates/pack-status.jinja");
pub const TEMPLATE_LIST: &str = include_str!("../templates/list.jinja");
pub const TEMPLATE_MESSAGE: &str = include_str!("../templates/message.jinja");
pub const TEMPLATE_PROBE: &str = include_str!("../templates/probe.jinja");
pub const TEMPLATE_GIT_FILTERS: &str = include_str!("../templates/git-filters.jinja");
pub const TEMPLATE_PROMPTS_LIST: &str = include_str!("../templates/prompts-list.jinja");
pub const TEMPLATE_TUTORIAL_INTRO: &str = include_str!("../templates/tutorial/intro.jinja");
pub const TEMPLATE_TUTORIAL_CHECK_ROOT: &str =
include_str!("../templates/tutorial/check_root.jinja");
pub const TEMPLATE_TUTORIAL_PICK_PACK: &str = include_str!("../templates/tutorial/pick_pack.jinja");
pub const TEMPLATE_TUTORIAL_NO_PACKS: &str = include_str!("../templates/tutorial/no_packs.jinja");
pub const TEMPLATE_TUTORIAL_SHOW_STATUS: &str =
include_str!("../templates/tutorial/show_status.jinja");
pub const TEMPLATE_TUTORIAL_ANNOTATE_STATUS: &str =
include_str!("../templates/tutorial/annotate_status.jinja");
pub const TEMPLATE_TUTORIAL_CONCEPT_TARGETS: &str =
include_str!("../templates/tutorial/concept_targets.jinja");
pub const TEMPLATE_TUTORIAL_CONCEPT_SHELL: &str =
include_str!("../templates/tutorial/concept_shell.jinja");
pub const TEMPLATE_TUTORIAL_DRY_RUN: &str = include_str!("../templates/tutorial/dry_run.jinja");
pub const TEMPLATE_TUTORIAL_REAL_UP: &str = include_str!("../templates/tutorial/real_up.jinja");
pub const TEMPLATE_TUTORIAL_OUTRO: &str = include_str!("../templates/tutorial/outro.jinja");
pub const TUTORIAL_STEP_TEMPLATES: &[(&str, &str)] = &[
("tutorial.intro", TEMPLATE_TUTORIAL_INTRO),
("tutorial.check_root", TEMPLATE_TUTORIAL_CHECK_ROOT),
("tutorial.pick_pack", TEMPLATE_TUTORIAL_PICK_PACK),
("tutorial.no_packs", TEMPLATE_TUTORIAL_NO_PACKS),
("tutorial.show_status", TEMPLATE_TUTORIAL_SHOW_STATUS),
(
"tutorial.annotate_status",
TEMPLATE_TUTORIAL_ANNOTATE_STATUS,
),
(
"tutorial.concept_targets",
TEMPLATE_TUTORIAL_CONCEPT_TARGETS,
),
("tutorial.concept_shell", TEMPLATE_TUTORIAL_CONCEPT_SHELL),
("tutorial.dry_run", TEMPLATE_TUTORIAL_DRY_RUN),
("tutorial.real_up", TEMPLATE_TUTORIAL_REAL_UP),
("tutorial.outro", TEMPLATE_TUTORIAL_OUTRO),
];
pub fn render_tutorial_step<T: serde::Serialize>(
step: &str,
data: &T,
mode: OutputMode,
) -> Result<String> {
let body = TUTORIAL_STEP_TEMPLATES
.iter()
.find_map(|(name, body)| (*name == step).then_some(*body))
.ok_or_else(|| crate::DodotError::Other(format!("unknown tutorial template: {step}")))?;
let theme = create_theme();
render_with_output(body, data, &theme, mode)
.map_err(|e| crate::DodotError::Other(format!("tutorial render: {e}")))
}
pub fn create_theme() -> Theme {
Theme::from_yaml(THEME_YAML).expect("built-in theme YAML must be valid")
}
pub fn create_renderer() -> Renderer {
let theme = create_theme();
let mut renderer = Renderer::new(theme).expect("renderer creation must succeed");
renderer
.add_template("pack-status", TEMPLATE_PACK_STATUS)
.unwrap();
renderer.add_template("list", TEMPLATE_LIST).unwrap();
renderer.add_template("message", TEMPLATE_MESSAGE).unwrap();
renderer.add_template("probe", TEMPLATE_PROBE).unwrap();
renderer
.add_template("git-filters", TEMPLATE_GIT_FILTERS)
.unwrap();
renderer
.add_template("prompts-list", TEMPLATE_PROMPTS_LIST)
.unwrap();
renderer
}
pub fn render<T: serde::Serialize>(
template_name: &str,
data: &T,
mode: OutputMode,
) -> Result<String> {
if matches!(mode, OutputMode::Json) {
return serde_json::to_string_pretty(data)
.map_err(|e| crate::DodotError::Other(format!("JSON serialization failed: {e}")));
}
let theme = create_theme();
let template = match template_name {
"pack-status" => TEMPLATE_PACK_STATUS,
"list" => TEMPLATE_LIST,
"message" => TEMPLATE_MESSAGE,
"probe" => TEMPLATE_PROBE,
"git-filters" => TEMPLATE_GIT_FILTERS,
"prompts-list" => TEMPLATE_PROMPTS_LIST,
other => {
return Err(crate::DodotError::Other(format!(
"unknown template: {other}"
)))
}
};
render_with_output(template, data, &theme, mode)
.map_err(|e| crate::DodotError::Other(format!("render failed: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn theme_parses_without_error() {
let _theme = create_theme();
}
#[test]
fn renderer_creates_with_all_templates() {
let _renderer = create_renderer();
}
#[test]
fn render_pack_status_text_mode() {
use serde::Serialize;
#[derive(Serialize)]
struct Data {
message: Option<String>,
dry_run: bool,
packs: Vec<Pack>,
}
#[derive(Serialize)]
struct Pack {
name: String,
files: Vec<File>,
}
#[derive(Serialize)]
struct File {
name: String,
symbol: String,
description: String,
status: String,
status_label: String,
}
let data = Data {
message: None,
dry_run: false,
packs: vec![Pack {
name: "vim".into(),
files: vec![File {
name: "vimrc".into(),
symbol: "➞".into(),
description: "~/.vimrc".into(),
status: "deployed".into(),
status_label: "deployed".into(),
}],
}],
};
let output = render("pack-status", &data, OutputMode::Text).unwrap();
assert!(output.contains("vim"));
assert!(output.contains("vimrc"));
assert!(output.contains("deployed"));
}
#[test]
fn all_tutorial_templates_render_in_text_mode() {
use crate::commands::tutorial::{TutorialCtx, TutorialPack};
let ctx = TutorialCtx {
dotfiles_root: "/home/example/dotfiles".into(),
via: "DOTFILES_ROOT env var".into(),
packs: vec![
TutorialPack {
name: "vim".into(),
kind: "config only".into(),
recommended: true,
},
TutorialPack {
name: "zsh".into(),
kind: "config + shell".into(),
recommended: false,
},
],
chosen_pack: Some("vim".into()),
chosen_pack_kind: Some("config only".into()),
status_output: Some("(rendered status would go here)".into()),
dry_run_output: Some("(dry-run output)".into()),
up_output: Some("(up output)".into()),
shell_integration: Some(crate::commands::tutorial::ShellIntegration {
shell_kind: "zsh".into(),
rc_path: "~/.zshrc".into(),
rc_path_abs: std::path::PathBuf::new(),
line_present: false,
eval_line: r#"eval "$(dodot init-sh)""#.into(),
}),
eval_line: r#"eval "$(dodot init-sh)""#.into(),
..Default::default()
};
for (name, _) in TUTORIAL_STEP_TEMPLATES {
let out = render_tutorial_step(name, &ctx, OutputMode::Text)
.unwrap_or_else(|e| panic!("template {name} failed: {e}"));
assert!(!out.is_empty(), "template {name} produced empty output");
}
}
#[test]
fn json_mode_produces_json() {
use serde::Serialize;
#[derive(Serialize)]
struct Data {
name: String,
}
let data = Data {
name: "test".into(),
};
let output = render("list", &data, OutputMode::Json).unwrap();
assert!(output.contains("\"name\""));
assert!(output.contains("\"test\""));
}
}