helixforge 1.2.0

Build, configure and optimise a Linux machine from a terminal menu: installs, dotfiles, updates, backups and health checks, every change planned and confirmed first.
//! The Markdown walkthrough of the catalog.

use helixforge_core::{Item, Tab};

pub fn walkthrough(tabs: &[Tab]) -> String {
    let mut text = String::from(
        "<!-- Generated by helixforge docgen; do not edit by hand. -->\n# Walkthrough\n",
    );
    for tab in tabs {
        text.push_str(&format!("\n## {}\n\n", tab.name));
        items(&tab.items, 3, &mut text);
    }
    text
}

/// A level's own entries first, then its groups as headings one level down,
/// so no entry reads as part of the group above it.
fn items(list: &[Item], level: usize, text: &mut String) {
    for item in list {
        if let Item::Entry(entry) = item
            && !entry.description.is_empty()
        {
            text.push_str(&format!("- **{}**: {}\n", entry.name, entry.description));
        }
    }
    for item in list {
        if let Item::Group(group) = item {
            text.push_str(&format!(
                "\n{} {}\n\n",
                "#".repeat(level.min(6)),
                group.name
            ));
            if !group.description.is_empty() {
                text.push_str(&format!("{}\n\n", group.description));
            }
            items(&group.items, level + 1, text);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use helixforge_core::{Action, Entry, Group};

    #[test]
    fn groups_become_headings_and_described_entries_bullets() {
        let entry = |name: &str, description: &str| {
            Item::Entry(Entry {
                id: name.to_lowercase(),
                name: name.into(),
                description: description.into(),
                flags: String::new(),
                multi_select: true,
                writes: Vec::new(),
                action: Action::Command {
                    line: "true".into(),
                },
                detect: None,
                needs: Default::default(),
            })
        };
        let tab = Tab {
            id: "apps".into(),
            name: "Apps".into(),
            items: vec![
                Item::Group(Group {
                    id: "browsers".into(),
                    name: "Browsers".into(),
                    description: String::new(),
                    multi_select: true,
                    items: vec![
                        entry("Brave", "A browser."),
                        Item::Group(Group {
                            id: "nightly".into(),
                            name: "Nightly".into(),
                            description: String::new(),
                            multi_select: true,
                            items: vec![entry("Firefox", "Another.")],
                        }),
                    ],
                }),
                entry("Quiet", ""),
                entry("Loose", "Outside any group."),
            ],
        };
        let text = walkthrough(&[tab]);
        assert!(text.contains("## Apps\n"));
        assert!(text.contains("### Browsers\n"));
        assert!(text.contains("- **Brave**: A browser.\n"));
        assert!(!text.contains("Quiet"));
        assert!(text.contains("#### Nightly\n\n- **Firefox**: Another.\n"));
        // An entry listed after a group still comes before its heading.
        let loose = text.find("- **Loose**").unwrap();
        assert!(loose < text.find("### Browsers").unwrap(), "{text}");
    }
}