Skip to main content

rns_git/
pages.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::{Command, Output, Stdio};
5use std::thread;
6use std::time::{Duration, Instant};
7
8use rns_core::msgpack::{self, Value};
9use rns_core::types::IdentityHash;
10use rns_crypto::identity::Identity;
11use rns_net::link_manager::ResourceStrategy;
12use rns_net::{Destination, RequestResponse, RnsNode};
13
14use crate::acl::{Access, Operation};
15use crate::config::ServerConfig;
16use crate::protocol;
17use crate::util::validate_repo_name;
18use crate::{Error, Result};
19
20pub const APP_NAME: &str = "nomadnetwork";
21pub const ASPECT_NODE: &str = "node";
22
23pub const PATH_INDEX: &str = "/page/index.mu";
24pub const PATH_GROUP: &str = "/page/group.mu";
25pub const PATH_REPO: &str = "/page/repo.mu";
26pub const PATH_TREE: &str = "/page/tree.mu";
27pub const PATH_BLOB: &str = "/page/blob.mu";
28pub const PATH_COMMITS: &str = "/page/commits.mu";
29pub const PATH_COMMIT: &str = "/page/commit.mu";
30pub const PATH_REFS: &str = "/page/refs.mu";
31pub const PATH_STATS: &str = "/page/stats.mu";
32pub const PATH_RELEASES: &str = "/page/releases.mu";
33pub const PATH_RELEASE: &str = "/page/release.mu";
34pub const PATH_WORK: &str = "/page/work.mu";
35pub const PATH_WORK_DOC: &str = "/page/work_doc.mu";
36pub const PATH_DOWNLOAD: &str = "/file/download";
37
38const GIT_COMMAND_TIMEOUT: Duration = Duration::from_secs(8);
39
40const PAGE_PATHS: &[&str] = &[
41    PATH_INDEX,
42    PATH_GROUP,
43    PATH_REPO,
44    PATH_TREE,
45    PATH_BLOB,
46    PATH_COMMITS,
47    PATH_COMMIT,
48    PATH_REFS,
49    PATH_STATS,
50    PATH_RELEASES,
51    PATH_RELEASE,
52    PATH_WORK,
53    PATH_WORK_DOC,
54];
55
56pub fn destination_for_identity(identity: &Identity) -> Destination {
57    Destination::single_in(APP_NAME, &[ASPECT_NODE], IdentityHash(*identity.hash()))
58}
59
60pub fn register_nomadnet_destination(
61    node: &RnsNode,
62    config: &ServerConfig,
63    identity: &Identity,
64) -> Result<Destination> {
65    let destination = destination_for_identity(identity);
66    let public_key = identity
67        .get_public_key()
68        .ok_or_else(|| Error::msg("repository identity has no public key"))?;
69    let private_key = identity
70        .get_private_key()
71        .ok_or_else(|| Error::msg("repository identity has no private key"))?;
72    let sig_prv: [u8; 32] = private_key[32..64].try_into().unwrap();
73    let sig_pub: [u8; 32] = public_key[32..64].try_into().unwrap();
74
75    node.register_link_destination(
76        destination.hash.0,
77        sig_prv,
78        sig_pub,
79        ResourceStrategy::AcceptAll as u8,
80    )
81    .map_err(|_| Error::msg("failed to register Nomad Network page destination"))?;
82
83    let access = Access::new(
84        &config.allow_read,
85        &config.allow_write,
86        &config.allow_create,
87        &config.allow_stats,
88        &config.allow_release,
89        &config.allow_interact,
90        &config.allow_admin,
91        config.repositories_dir.clone(),
92    )?;
93    register_page_handlers(node, config.clone(), access)?;
94
95    Ok(destination)
96}
97
98fn register_page_handlers(node: &RnsNode, config: ServerConfig, access: Access) -> Result<()> {
99    for path in PAGE_PATHS {
100        let handler_path = *path;
101        let handler_config = config.clone();
102        let handler_access = access.clone();
103        node.register_request_handler(handler_path, None, move |_link, path, data, remote| {
104            let remote_hash = remote.map(|(hash, _)| hash);
105            Some(
106                match render_page(path, &handler_config, &handler_access, data, remote_hash) {
107                    Ok(page) => page.into_bytes(),
108                    Err(err) => error_page(&handler_config.node_name, &err.to_string()),
109                },
110            )
111        })
112        .map_err(|_| Error::msg(format!("failed to register page handler {handler_path}")))?;
113    }
114    let download_config = config.clone();
115    let download_access = access.clone();
116    node.register_request_handler_response(
117        PATH_DOWNLOAD,
118        None,
119        move |_link, _path, data, remote| {
120            let remote_hash = remote.map(|(hash, _)| hash);
121            Some(
122                download_file(&download_config, &download_access, data, remote_hash)
123                    .unwrap_or_else(|err| RequestResponse::Bytes(error_response_bytes(&err))),
124            )
125        },
126    )
127    .map_err(|_| Error::msg("failed to register file download handler"))?;
128    Ok(())
129}
130
131pub fn render_page(
132    path: &str,
133    config: &ServerConfig,
134    access: &Access,
135    data: &[u8],
136    remote: Option<&[u8; 16]>,
137) -> Result<String> {
138    let vars = parse_page_vars(data)?;
139    let (template, content) = match path {
140        PATH_INDEX => ("front", render_front_page(config, access, remote)?),
141        PATH_GROUP => ("group", render_group_page(config, access, remote, &vars)?),
142        PATH_REPO => ("repo", render_repo_page(config, access, remote, &vars)?),
143        PATH_TREE => ("tree", render_tree_page(config, access, remote, &vars)?),
144        PATH_BLOB => ("blob", render_blob_page(config, access, remote, &vars)?),
145        PATH_COMMITS => (
146            "commits",
147            render_commits_page(config, access, remote, &vars)?,
148        ),
149        PATH_COMMIT => ("commit", render_commit_page(config, access, remote, &vars)?),
150        PATH_REFS => ("refs", render_refs_page(config, access, remote, &vars)?),
151        PATH_STATS => ("stats", render_stats_page(config, access, remote, &vars)?),
152        PATH_RELEASES => (
153            "releases",
154            render_releases_page(config, access, remote, &vars)?,
155        ),
156        PATH_RELEASE => (
157            "release",
158            render_release_page(config, access, remote, &vars)?,
159        ),
160        PATH_WORK => ("work", render_work_page(config, access, remote, &vars)?),
161        PATH_WORK_DOC => (
162            "work_doc",
163            render_work_doc_page(config, access, remote, &vars)?,
164        ),
165        _ => return Err(Error::msg("unknown page path")),
166    };
167    record_page_view(path, config, &vars, remote);
168    Ok(render_template(config, template, &content))
169}
170
171fn record_page_view(
172    path: &str,
173    config: &ServerConfig,
174    vars: &BTreeMap<String, String>,
175    remote: Option<&[u8; 16]>,
176) {
177    match path {
178        PATH_INDEX => crate::stats::record_front_view(config, remote),
179        PATH_GROUP => {
180            if let Some(group) = var(vars, "g") {
181                crate::stats::record_group_view(config, group, remote);
182            }
183        }
184        PATH_REPO | PATH_TREE | PATH_BLOB | PATH_COMMITS | PATH_COMMIT | PATH_REFS
185        | PATH_RELEASES | PATH_RELEASE | PATH_WORK | PATH_WORK_DOC => {
186            if let (Some(group), Some(repo)) = (var(vars, "g"), var(vars, "r")) {
187                crate::stats::record_repository_view(config, group, repo, remote);
188            }
189        }
190        _ => {}
191    }
192}
193
194fn parse_page_vars(data: &[u8]) -> Result<BTreeMap<String, String>> {
195    if data.is_empty() {
196        return Ok(BTreeMap::new());
197    }
198    let value =
199        msgpack::unpack_exact(data).map_err(|e| Error::msg(format!("invalid msgpack: {e}")))?;
200    let map = value
201        .as_map()
202        .ok_or_else(|| Error::msg("page request must be a msgpack map"))?;
203    let mut out = BTreeMap::new();
204    for (key, value) in map {
205        let Some(key) = key.as_str() else {
206            continue;
207        };
208        let value = match value {
209            Value::Str(value) => value.clone(),
210            Value::UInt(value) => value.to_string(),
211            Value::Int(value) => value.to_string(),
212            Value::Bool(value) => {
213                if *value {
214                    "true".to_string()
215                } else {
216                    "false".to_string()
217                }
218            }
219            _ => continue,
220        };
221        out.insert(key.to_string(), value);
222    }
223    Ok(out)
224}
225
226fn render_template(config: &ServerConfig, template: &str, content: &str) -> String {
227    let page = apply_template(&page_template(config, template), content, config);
228    apply_template(&base_template(config), &page, config)
229}
230
231fn error_page(node_name: &str, message: &str) -> Vec<u8> {
232    default_base_template()
233        .replace("{PAGE_CONTENT}", &format!(">Error\n\n{message}\n"))
234        .replace("{NODE_NAME}", &m_escape(node_name))
235        .replace("{VERSION}", env!("CARGO_PKG_VERSION"))
236        .replace("{NAVIGATION}", "")
237        .replace("{GEN_TIME}", "0ms")
238        .into_bytes()
239}
240
241fn error_response_bytes(err: &Error) -> Vec<u8> {
242    protocol::status_bytes(protocol::RES_INVALID_REQ, err.to_string())
243}
244
245fn base_template(config: &ServerConfig) -> String {
246    load_template(config, "base").unwrap_or_else(|| default_base_template().to_string())
247}
248
249fn page_template(config: &ServerConfig, template: &str) -> String {
250    load_template(config, template).unwrap_or_else(|| "{PAGE_CONTENT}".to_string())
251}
252
253fn load_template(config: &ServerConfig, template: &str) -> Option<String> {
254    if !template
255        .chars()
256        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
257    {
258        return None;
259    }
260    let path = config.templates_dir.join(format!("{template}.mu"));
261    fs::read_to_string(path)
262        .ok()
263        .map(|template| template.trim_end().to_string())
264}
265
266fn apply_template(template: &str, content: &str, config: &ServerConfig) -> String {
267    template
268        .replace("{NODE_NAME}", &m_escape(&config.node_name))
269        .replace("{VERSION}", env!("CARGO_PKG_VERSION"))
270        .replace("{NAVIGATION}", "")
271        .replace("{GEN_TIME}", "0ms")
272        .replace("{PAGE_CONTENT}", content)
273}
274
275fn default_base_template() -> &'static str {
276    "#!c=0\n>{NODE_NAME}\n\n{PAGE_CONTENT}\n<\n-\n`a`F666`[Served by rngit`:/page/index.mu] - Generated by rngit`f\n"
277}
278
279fn icon(config: &ServerConfig, name: &str) -> &'static str {
280    if !config.unicode_icons {
281        return "";
282    }
283    match name {
284        "sep" => "•",
285        "folder" => "🗀",
286        "file" => "🗎",
287        "branch" => "⑃",
288        "tag" => "⌆",
289        "commits" => "🖹",
290        "package" => "▣",
291        "stats" => "🗠",
292        "heart" => "♥",
293        _ => "",
294    }
295}
296
297fn icon_label(config: &ServerConfig, name: &str, label: &str) -> String {
298    let icon = icon(config, name);
299    if icon.is_empty() {
300        label.to_string()
301    } else {
302        format!("{icon} {label}")
303    }
304}
305
306fn icon_sep(config: &ServerConfig) -> &'static str {
307    let sep = icon(config, "sep");
308    if sep.is_empty() {
309        "•"
310    } else {
311        sep
312    }
313}
314
315fn m_link(label: &str, path: &str, fields: &[(&str, &str)]) -> String {
316    format!("`!{}`!", m_link_raw(label, path, fields))
317}
318
319fn m_link_raw(label: &str, path: &str, fields: &[(&str, &str)]) -> String {
320    let mut out = String::from("`[");
321    out.push_str(&sanitize_label(label));
322    out.push_str("`:");
323    out.push_str(path);
324    if !fields.is_empty() {
325        out.push('`');
326        for (i, (key, value)) in fields.iter().enumerate() {
327            if i > 0 {
328                out.push('|');
329            }
330            out.push_str(key);
331            out.push('=');
332            out.push_str(&sanitize_field(value));
333        }
334    }
335    out.push(']');
336    out
337}
338
339fn markdown_blob_url_scope(group: &str, repo: &str, reference: &str, current_path: &str) -> String {
340    let directory = current_path
341        .rsplit_once('/')
342        .map(|(directory, _)| format!("{directory}/"))
343        .unwrap_or_default();
344    let mut out = format!(":{PATH_BLOB}`");
345    for (index, (key, value)) in [
346        ("g", group),
347        ("r", repo),
348        ("ref", reference),
349        ("path", directory.as_str()),
350    ]
351    .iter()
352    .enumerate()
353    {
354        if index > 0 {
355            out.push('|');
356        }
357        out.push_str(key);
358        out.push('=');
359        out.push_str(&sanitize_field(value));
360    }
361    out
362}
363
364fn sanitize_label(value: &str) -> String {
365    value
366        .chars()
367        .filter(|ch| *ch != '[' && *ch != ']' && *ch != '`')
368        .collect()
369}
370
371fn sanitize_field(value: &str) -> String {
372    let mut out = String::new();
373    for byte in value.bytes() {
374        match byte {
375            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'/' => {
376                out.push(byte as char)
377            }
378            b' ' => out.push('+'),
379            _ => out.push_str(&format!("%{byte:02X}")),
380        }
381    }
382    out
383}
384
385fn m_escape(value: &str) -> String {
386    value
387        .replace('\\', "\\\\")
388        .replace('`', "\\`")
389        .replace('\t', "   ")
390}
391
392fn m_escape_line_start_controls(value: &str) -> String {
393    let escaped = m_escape(value);
394    if value.starts_with('-') || value.starts_with('>') || value.starts_with('<') {
395        format!("\\{escaped}")
396    } else {
397        escaped
398    }
399}
400
401fn join_git_path(parent: &str, child: &str) -> String {
402    if parent.is_empty() {
403        child.to_string()
404    } else {
405        format!("{parent}/{child}")
406    }
407}
408
409fn format_size(size: u64) -> String {
410    const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB"];
411    let mut value = size as f64;
412    let mut unit = 0;
413    while value >= 1024.0 && unit + 1 < UNITS.len() {
414        value /= 1024.0;
415        unit += 1;
416    }
417    if unit == 0 {
418        format!("{size} B")
419    } else {
420        format!("{value:.1} {}", UNITS[unit])
421    }
422}
423
424fn format_unix_time(timestamp: i64) -> String {
425    timestamp.to_string()
426}
427
428fn render_front_page(
429    config: &ServerConfig,
430    access: &Access,
431    remote: Option<&[u8; 16]>,
432) -> Result<String> {
433    let groups = accessible_groups(config, access, remote)?;
434    let mut out = format!(">>\n{} /\n\n>Groups\n\n", m_link("Node", PATH_INDEX, &[]));
435    if groups.is_empty() {
436        out.push_str("No repository groups available.\n");
437        return Ok(out);
438    }
439    for (group, repos) in groups {
440        out.push_str(&format!(
441            "{} ({} repositories)\n",
442            m_link(&group, PATH_GROUP, &[("g", &group)]),
443            repos.len()
444        ));
445        for repo in repos {
446            out.push_str(&format!(
447                "  - {}\n",
448                m_link_raw(&repo, PATH_REPO, &[("g", &group), ("r", &repo)])
449            ));
450        }
451    }
452    Ok(out)
453}
454
455fn render_group_page(
456    config: &ServerConfig,
457    access: &Access,
458    remote: Option<&[u8; 16]>,
459    vars: &BTreeMap<String, String>,
460) -> Result<String> {
461    let group = required_var(vars, "g")?;
462    validate_group_name(group)?;
463    let repos = accessible_repositories(config, access, remote, group)?;
464    if repos.is_empty() {
465        return Ok(">Group Not Found\n\nThe requested group was not found.\n".to_string());
466    }
467
468    let mut out = format!(
469        ">>\n{} / {}\n\n>Repositories\n\n",
470        m_link("Node", PATH_INDEX, &[]),
471        m_escape(group)
472    );
473    for repo in repos {
474        let repo_path = config.repositories_dir.join(group).join(&repo);
475        let description = repository_description(&repo_path)?;
476        out.push_str(&format!(
477            "- {}",
478            m_link(&repo, PATH_REPO, &[("g", group), ("r", &repo)])
479        ));
480        if !description.is_empty() {
481            out.push_str(&format!(" - {}", m_escape(&description)));
482        }
483        out.push('\n');
484    }
485    Ok(out)
486}
487
488fn render_repo_page(
489    config: &ServerConfig,
490    access: &Access,
491    remote: Option<&[u8; 16]>,
492    vars: &BTreeMap<String, String>,
493) -> Result<String> {
494    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
495    let description = repository_description(&repository)?;
496    let refs = git_refs(&repository)?;
497    let readme = readme_content(&repository)?;
498    let repo_url = format!("rns://<repository-destination>/{group}/{repo}");
499    let thanks = repository_thanks(&repository, var(vars, "thanks").is_some())?;
500    let releases_path = crate::release::release_sidecar_path(&repository);
501    let release_count = crate::release::list_releases(&releases_path)?
502        .into_iter()
503        .filter(|release| release.status == "published")
504        .count();
505    let work_path = crate::work::work_sidecar_path(&repository);
506    let work_lists = crate::work::list_documents(&work_path, crate::work::WorkListScope::All)?;
507    let work_count = work_lists.active.len() + work_lists.completed.len();
508
509    let branch_count = refs.heads.len().to_string();
510    let tag_count = refs.tags.len().to_string();
511    let mut out = format!(
512        ">>\n{} / {} / {} `F666{}`f\n\n",
513        m_link("Node", PATH_INDEX, &[]),
514        m_link(&group, PATH_GROUP, &[("g", &group)]),
515        m_escape(&repo),
516        m_escape(&repo_url)
517    );
518    if !description.is_empty() {
519        out.push_str(&format!("{}\n\n", m_escape(&description)));
520    }
521    let mut nav_links = vec![
522        m_link_raw(
523            &icon_label(config, "folder", "Files"),
524            PATH_TREE,
525            &[("g", &group), ("r", &repo), ("ref", "HEAD")],
526        ),
527        m_link_raw(
528            &icon_label(config, "commits", "Commits"),
529            PATH_COMMITS,
530            &[("g", &group), ("r", &repo), ("ref", "HEAD")],
531        ),
532        m_link_raw(
533            &icon_label(config, "branch", &format!("Branches ({branch_count})")),
534            PATH_REFS,
535            &[("g", &group), ("r", &repo), ("type", "heads")],
536        ),
537        m_link_raw(
538            &icon_label(config, "tag", &format!("Tags ({tag_count})")),
539            PATH_REFS,
540            &[("g", &group), ("r", &repo), ("type", "tags")],
541        ),
542        m_link_raw(
543            &icon_label(config, "heart", &format!("Thanks ({thanks})")),
544            PATH_REPO,
545            &[("g", &group), ("r", &repo), ("thanks", "y")],
546        ),
547    ];
548    if release_count > 0 {
549        nav_links.push(m_link_raw(
550            &icon_label(config, "package", &format!("Releases ({release_count})")),
551            PATH_RELEASES,
552            &[("g", &group), ("r", &repo)],
553        ));
554    }
555    if work_count > 0 {
556        nav_links.push(m_link_raw(
557            &icon_label(config, "work", &format!("Work ({work_count})")),
558            PATH_WORK,
559            &[("g", &group), ("r", &repo)],
560        ));
561    }
562    if access.allows(Operation::Stats, &format!("{group}/{repo}"), remote)? {
563        nav_links.push(m_link_raw(
564            &icon_label(config, "stats", "Stats"),
565            PATH_STATS,
566            &[("g", &group), ("r", &repo)],
567        ));
568    }
569    let sep = format!(" {} ", icon_sep(config));
570    out.push_str(&format!("{}\n\n", nav_links.join(&sep)));
571    if let Some(readme) = readme {
572        if !readme.content.trim_start().starts_with('>') {
573            out.push_str("-\n");
574        }
575        if readme.markdown {
576            out.push_str(&markdown_to_micron_scoped(
577                &readme.content,
578                Some(&markdown_blob_url_scope(&group, &repo, "HEAD", "README.md")),
579            ));
580        } else {
581            out.push_str(&readme.content);
582        }
583        if !out.ends_with('\n') {
584            out.push('\n');
585        }
586    } else {
587        out.push_str("No README file found in this repository.\n");
588    }
589    Ok(out)
590}
591
592fn render_tree_page(
593    config: &ServerConfig,
594    access: &Access,
595    remote: Option<&[u8; 16]>,
596    vars: &BTreeMap<String, String>,
597) -> Result<String> {
598    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
599    let reference = var(vars, "ref").unwrap_or("HEAD");
600    let path = var(vars, "path").unwrap_or("");
601    validate_git_path(path)?;
602    let resolved = resolve_ref(&repository, reference)?;
603    let entries = tree_entries(&repository, &resolved, path)?;
604    let title_path = if path.is_empty() { "/" } else { path };
605
606    let mut out = format!(
607        ">>\n{} / {} / {} / files / {}\n\n>Contents: {} ({})\n\n",
608        m_link("Node", PATH_INDEX, &[]),
609        m_link(&group, PATH_GROUP, &[("g", &group)]),
610        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
611        m_escape(title_path),
612        m_escape(reference),
613        &resolved[..8]
614    );
615    if entries.is_empty() {
616        out.push_str("Empty directory.\n");
617    } else {
618        for entry in entries {
619            let entry_path = join_git_path(path, &entry.name);
620            if entry.kind == "tree" {
621                let label = icon_label(config, "folder", &format!("{}/", entry.name));
622                out.push_str(&format!(
623                    "{}\n",
624                    m_link_raw(
625                        &label,
626                        PATH_TREE,
627                        &[
628                            ("g", &group),
629                            ("r", &repo),
630                            ("ref", reference),
631                            ("path", &entry_path)
632                        ]
633                    )
634                ));
635            } else {
636                let label = icon_label(config, "file", &entry.name);
637                out.push_str(&format!(
638                    "{} {}\n",
639                    m_link_raw(
640                        &label,
641                        PATH_BLOB,
642                        &[
643                            ("g", &group),
644                            ("r", &repo),
645                            ("ref", reference),
646                            ("path", &entry_path)
647                        ]
648                    ),
649                    entry.size_label()
650                ));
651            }
652        }
653    }
654    Ok(out)
655}
656
657fn render_blob_page(
658    config: &ServerConfig,
659    access: &Access,
660    remote: Option<&[u8; 16]>,
661    vars: &BTreeMap<String, String>,
662) -> Result<String> {
663    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
664    let reference = var(vars, "ref").unwrap_or("HEAD");
665    let path = required_var(vars, "path")?;
666    validate_git_path(path)?;
667    let resolved = resolve_ref(&repository, reference)?;
668    let blob = blob_info(&repository, &resolved, path)?;
669    let renderable = renderable_blob(path);
670    let raw_requested = var(vars, "raw").is_some();
671    let render_requested = var(vars, "render").is_some();
672    let render =
673        renderable.is_some() && !raw_requested && (render_requested || render_by_default(path));
674    let download_link = m_link(
675        "Download",
676        PATH_DOWNLOAD,
677        &[
678            ("g", &group),
679            ("r", &repo),
680            ("ref", reference),
681            ("path", path),
682        ],
683    );
684    let controls = if renderable.is_some() {
685        let render_link = m_link(
686            "View rendered",
687            PATH_BLOB,
688            &[
689                ("g", &group),
690                ("r", &repo),
691                ("ref", reference),
692                ("path", path),
693                ("render", "y"),
694            ],
695        );
696        let raw_link = m_link(
697            "View raw",
698            PATH_BLOB,
699            &[
700                ("g", &group),
701                ("r", &repo),
702                ("ref", reference),
703                ("path", path),
704                ("raw", "y"),
705            ],
706        );
707        let sep = icon_sep(config);
708        Some(if render {
709            format!("Displaying Rendered {sep} {raw_link}")
710        } else {
711            format!("Displaying Raw {sep} {render_link} {sep} {download_link}")
712        })
713    } else {
714        Some(format!(
715            "Displaying Raw {} {download_link}",
716            icon_sep(config)
717        ))
718    };
719    let content = if !blob.displayable {
720        crate::highlight::plain_literal_block(&blob.content)
721    } else if render {
722        match renderable {
723            Some(RenderableBlob::Markdown) => markdown_to_micron_scoped(
724                &blob.content,
725                Some(&markdown_blob_url_scope(&group, &repo, reference, path)),
726            ),
727            Some(RenderableBlob::Micron) => {
728                let mut out = blob.content.clone();
729                if !out.ends_with('\n') {
730                    out.push('\n');
731                }
732                out
733            }
734            None => crate::highlight::literal_block(&blob.content, Some(path), None),
735        }
736    } else {
737        crate::highlight::literal_block(&blob.content, Some(path), None)
738    };
739    let controls = controls
740        .map(|controls| format!("{controls}\n\n"))
741        .unwrap_or_default();
742    Ok(format!(
743        ">>\n{} / {} / {} / {}\n\n>{} `F666{} ({}, {})`f\n\n{}{}",
744        m_link("Node", PATH_INDEX, &[]),
745        m_link(&group, PATH_GROUP, &[("g", &group)]),
746        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
747        m_escape(path),
748        m_escape(path),
749        &resolved[..8],
750        if blob.binary { "Binary" } else { "Text" },
751        format_size(blob.size),
752        controls,
753        content
754    ))
755}
756
757#[derive(Debug, Clone, Copy)]
758enum RenderableBlob {
759    Markdown,
760    Micron,
761}
762
763fn renderable_blob(path: &str) -> Option<RenderableBlob> {
764    match Path::new(path)
765        .extension()
766        .and_then(|extension| extension.to_str())
767        .map(str::to_ascii_lowercase)
768        .as_deref()
769    {
770        Some("md") => Some(RenderableBlob::Markdown),
771        Some("mu") => Some(RenderableBlob::Micron),
772        _ => None,
773    }
774}
775
776fn render_by_default(path: &str) -> bool {
777    renderable_blob(path).is_some()
778}
779
780fn render_commits_page(
781    config: &ServerConfig,
782    access: &Access,
783    remote: Option<&[u8; 16]>,
784    vars: &BTreeMap<String, String>,
785) -> Result<String> {
786    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
787    let reference = var(vars, "ref").unwrap_or("HEAD");
788    let resolved = resolve_ref(&repository, reference)?;
789    let commits = commits(&repository, &resolved, 100)?;
790
791    let mut out = format!(
792        ">>\n{} / {} / {} / commits\n\n>Commits `F666{} ({})`f\n\n",
793        m_link("Node", PATH_INDEX, &[]),
794        m_link(&group, PATH_GROUP, &[("g", &group)]),
795        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
796        m_escape(reference),
797        &resolved[..8]
798    );
799    if commits.is_empty() {
800        out.push_str("No commits found.\n");
801    } else {
802        for commit in commits {
803            out.push_str(&format!(
804                "{} {} `F666{}`f\n{}\n\n",
805                m_link_raw(
806                    &commit.hash[..7],
807                    PATH_COMMIT,
808                    &[("g", &group), ("r", &repo), ("h", &commit.hash)]
809                ),
810                m_escape(&commit.author),
811                format_unix_time(commit.timestamp),
812                m_escape(&commit.subject)
813            ));
814        }
815    }
816    Ok(out)
817}
818
819fn render_commit_page(
820    config: &ServerConfig,
821    access: &Access,
822    remote: Option<&[u8; 16]>,
823    vars: &BTreeMap<String, String>,
824) -> Result<String> {
825    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
826    let hash = required_var(vars, "h")?;
827    validate_refish(hash)?;
828    let commit = commit_info(&repository, hash)?;
829    let mut out = format!(
830        ">>\n{} / {} / {} / commit {}\n\n>Commit {}\n\n{}\nAuthor: {} <{}>\nDate: {}\n",
831        m_link("Node", PATH_INDEX, &[]),
832        m_link(&group, PATH_GROUP, &[("g", &group)]),
833        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
834        &commit.hash[..7],
835        commit.hash,
836        m_escape(&commit.subject),
837        m_escape(&commit.author),
838        m_escape(&commit.email),
839        format_unix_time(commit.timestamp)
840    );
841    if !commit.body.is_empty() {
842        out.push_str(&format!("\n{}\n", m_escape(&commit.body)));
843    }
844    out.push_str("\n>Files changed\n\n");
845    for file in commit.files {
846        out.push_str(&format!("{} {}\n", file.status, m_escape(&file.path)));
847    }
848    Ok(out)
849}
850
851fn render_refs_page(
852    config: &ServerConfig,
853    access: &Access,
854    remote: Option<&[u8; 16]>,
855    vars: &BTreeMap<String, String>,
856) -> Result<String> {
857    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
858    let refs = git_refs(&repository)?;
859    let mut out = format!(
860        ">>\n{} / {} / {} / refs\n\n>Refs\n\n>Branches\n",
861        m_link("Node", PATH_INDEX, &[]),
862        m_link(&group, PATH_GROUP, &[("g", &group)]),
863        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)])
864    );
865    for branch in refs.heads {
866        out.push_str(&format!(
867            "- {} `F666{}`f\n",
868            m_link_raw(
869                &branch.name,
870                PATH_TREE,
871                &[("g", &group), ("r", &repo), ("ref", &branch.name)]
872            ),
873            &branch.sha[..8]
874        ));
875    }
876    out.push_str("\n>Tags\n");
877    for tag in refs.tags {
878        out.push_str(&format!(
879            "- {} `F666{}`f\n",
880            m_link_raw(
881                &tag.name,
882                PATH_TREE,
883                &[("g", &group), ("r", &repo), ("ref", &tag.name)]
884            ),
885            &tag.sha[..8]
886        ));
887    }
888    Ok(out)
889}
890
891fn render_stats_page(
892    config: &ServerConfig,
893    access: &Access,
894    remote: Option<&[u8; 16]>,
895    vars: &BTreeMap<String, String>,
896) -> Result<String> {
897    let (group, repo, _repository) = accessible_repository(config, access, remote, vars)?;
898    if !access.allows(Operation::Stats, &format!("{group}/{repo}"), remote)? {
899        return Ok(">>\n>Error\n\nThe requested repository was not found.\n".to_string());
900    }
901    let Some(stats) = crate::stats::repository_stats(config, access, remote, &group, &repo, 90)?
902    else {
903        return Ok(
904            ">>\n>Stats Unavailable\n\nCould not retrieve statistics for this repository.\n"
905                .to_string(),
906        );
907    };
908
909    let mut out = format!(
910        ">>\n{} / {} / {}\n\n>Stats for {}\n\n",
911        m_link("Node", PATH_INDEX, &[]),
912        m_link(&group, PATH_GROUP, &[("g", &group)]),
913        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
914        m_escape(&stats.repository)
915    );
916    out.push_str(&format!(
917        "`F66dViews`f    : {:>5}  total `F666(peak: {:>3})`f\n",
918        stats.views.total, stats.views.peak
919    ));
920    out.push_str(&format!(
921        "`F0a0Fetches`f  : {:>5}  total `F666(peak: {:>3})`f\n",
922        stats.fetches.total, stats.fetches.peak
923    ));
924    out.push_str(&format!(
925        "`Faa0Pushes`f   : {:>5}  total `F666(peak: {:>3})`f\n",
926        stats.pushes.total, stats.pushes.peak
927    ));
928    out.push_str(&format!(
929        "`F0aaActivity`f : {:>5} points\n\n",
930        stats.activity_score
931    ));
932    out.push_str(&format!(
933        "{}{}`f over the last {} days ({})\n\n",
934        stats.activity_level.color(),
935        stats.activity_level.label(),
936        stats.actual_days,
937        stats.date_range
938    ));
939
940    if stats.views.total > 0 {
941        out.push_str(">Views\n\n");
942        out.push_str(&render_chart(
943            &stats.views.daily,
944            &stats.timeline_labels,
945            "66d",
946            10,
947        ));
948        out.push('\n');
949    }
950    if stats.fetches.total > 0 {
951        out.push_str(">Fetches\n\n");
952        out.push_str(&render_chart(
953            &stats.fetches.daily,
954            &stats.timeline_labels,
955            "0a0",
956            10,
957        ));
958        out.push('\n');
959    }
960    if stats.pushes.total > 0 {
961        out.push_str(">Pushes\n\n");
962        out.push_str(&render_chart(
963            &stats.pushes.daily,
964            &stats.timeline_labels,
965            "aa0",
966            10,
967        ));
968        out.push('\n');
969    }
970    if stats.activity_score > 0 {
971        out.push_str(">Combined Activity\n\n");
972        out.push_str(&render_combined_chart(
973            &stats.views.daily,
974            &stats.fetches.daily,
975            &stats.pushes.daily,
976            &stats.timeline_labels,
977            4,
978        ));
979    } else {
980        out.push_str(
981            "`*\nNo activity recorded for this repository in the selected time period.\n\n`*",
982        );
983    }
984    Ok(out)
985}
986
987fn render_releases_page(
988    config: &ServerConfig,
989    access: &Access,
990    remote: Option<&[u8; 16]>,
991    vars: &BTreeMap<String, String>,
992) -> Result<String> {
993    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
994    let releases_path = crate::release::release_sidecar_path(&repository);
995    let releases: Vec<_> = crate::release::list_releases(&releases_path)?
996        .into_iter()
997        .filter(|release| release.status == "published")
998        .collect();
999    let mut out = format!(
1000        ">>\n{} / {} / {} / releases\n\n>Releases ({})\n\n",
1001        m_link("Node", PATH_INDEX, &[]),
1002        m_link(&group, PATH_GROUP, &[("g", &group)]),
1003        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
1004        releases.len()
1005    );
1006    if releases.is_empty() {
1007        out.push_str("No releases available for this repository.\n");
1008        return Ok(out);
1009    }
1010    for release in releases {
1011        out.push_str(&format!(
1012            "{} `F666{} artifacts`f\n",
1013            m_link(
1014                &release.tag,
1015                PATH_RELEASE,
1016                &[("g", &group), ("r", &repo), ("tag", &release.tag)]
1017            ),
1018            release.artifacts
1019        ));
1020        if !release.preview.is_empty() {
1021            append_release_preview(&mut out, &release.preview_format, &release.preview);
1022        }
1023        out.push('\n');
1024    }
1025    Ok(out)
1026}
1027
1028fn append_release_preview(out: &mut String, format: &str, preview: &str) {
1029    match format {
1030        "markdown" => out.push_str(&markdown_to_micron(preview)),
1031        "micron" => {
1032            out.push_str(preview);
1033            if !preview.ends_with('\n') {
1034                out.push('\n');
1035            }
1036        }
1037        _ => {
1038            out.push_str(&m_escape_line_start_controls(preview));
1039            out.push('\n');
1040        }
1041    }
1042}
1043
1044fn render_release_page(
1045    config: &ServerConfig,
1046    access: &Access,
1047    remote: Option<&[u8; 16]>,
1048    vars: &BTreeMap<String, String>,
1049) -> Result<String> {
1050    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
1051    let releases_path = crate::release::release_sidecar_path(&repository);
1052    let requested_tag = var(vars, "tag").unwrap_or("latest");
1053    let tag = if requested_tag == "latest" {
1054        let Some(tag) = crate::release::latest_published_tag(&releases_path)? else {
1055            return Ok(">Release Not Found\n\nNo latest release exists.\n".to_string());
1056        };
1057        tag
1058    } else {
1059        requested_tag.to_string()
1060    };
1061    let Some(release) = crate::release::release_data(&releases_path, &tag)? else {
1062        return Ok(">Release Not Found\n\nThe requested release was not found.\n".to_string());
1063    };
1064    if release.status != "published" {
1065        return Ok(">Release Not Found\n\nThe requested release was not found.\n".to_string());
1066    }
1067    let release_dir = releases_path.join(&release.tag);
1068    let thanks = crate::release::release_thanks(&release_dir, var(vars, "thanks").is_some())?;
1069    let mut out = format!(
1070        ">>\n{} / {} / {} / {} / {}\n\n>Release {}\n\n",
1071        m_link("Node", PATH_INDEX, &[]),
1072        m_link(&group, PATH_GROUP, &[("g", &group)]),
1073        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
1074        m_link("releases", PATH_RELEASES, &[("g", &group), ("r", &repo)]),
1075        m_escape(&release.tag),
1076        m_escape(&release.tag)
1077    );
1078    out.push_str(&format!(
1079        "{}\n\n",
1080        m_link(
1081            &icon_label(config, "heart", &format!("Thanks ({thanks})")),
1082            PATH_RELEASE,
1083            &[
1084                ("g", &group),
1085                ("r", &repo),
1086                ("tag", &release.tag),
1087                ("thanks", "y")
1088            ]
1089        )
1090    ));
1091    if !release.notes.is_empty() {
1092        match release.notes_format.as_str() {
1093            "markdown" => out.push_str(&markdown_to_micron(&release.notes)),
1094            "micron" => out.push_str(&release.notes),
1095            _ => out.push_str(&m_escape(&release.notes)),
1096        }
1097        if !out.ends_with('\n') {
1098            out.push('\n');
1099        }
1100        out.push('\n');
1101    }
1102    out.push_str(">Artifacts\n\n");
1103    if release.artifacts.is_empty() {
1104        out.push_str("No artifacts for this release.\n");
1105    } else {
1106        for artifact in release.artifacts {
1107            out.push_str(&format!(
1108                "{} ({})\n",
1109                m_link(
1110                    &artifact.name,
1111                    PATH_DOWNLOAD,
1112                    &[
1113                        ("g", &group),
1114                        ("r", &repo),
1115                        ("tag", &release.tag),
1116                        ("artifact", &artifact.name)
1117                    ]
1118                ),
1119                format_size(artifact.size)
1120            ));
1121        }
1122    }
1123    Ok(out)
1124}
1125
1126fn render_work_page(
1127    config: &ServerConfig,
1128    access: &Access,
1129    remote: Option<&[u8; 16]>,
1130    vars: &BTreeMap<String, String>,
1131) -> Result<String> {
1132    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
1133    let scope = var(vars, "scope").unwrap_or("active");
1134    let list_scope =
1135        crate::work::WorkListScope::parse(scope).ok_or_else(|| Error::msg("invalid scope"))?;
1136    let lists =
1137        crate::work::list_documents(&crate::work::work_sidecar_path(&repository), list_scope)?;
1138    let mut out = format!(
1139        ">>\n{} / {} / {} / work\n\n>Work Documents\n\n",
1140        m_link("Node", PATH_INDEX, &[]),
1141        m_link(&group, PATH_GROUP, &[("g", &group)]),
1142        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)])
1143    );
1144    let tabs = [
1145        m_link_raw(
1146            "Active",
1147            PATH_WORK,
1148            &[("g", &group), ("r", &repo), ("scope", "active")],
1149        ),
1150        m_link_raw(
1151            "Completed",
1152            PATH_WORK,
1153            &[("g", &group), ("r", &repo), ("scope", "completed")],
1154        ),
1155        m_link_raw(
1156            "All",
1157            PATH_WORK,
1158            &[("g", &group), ("r", &repo), ("scope", "all")],
1159        ),
1160    ];
1161    out.push_str(&format!(
1162        "{}\n\n",
1163        tabs.join(&format!(" {} ", icon_sep(config)))
1164    ));
1165    if matches!(
1166        list_scope,
1167        crate::work::WorkListScope::Active | crate::work::WorkListScope::All
1168    ) {
1169        append_work_section(
1170            &mut out,
1171            &group,
1172            &repo,
1173            crate::work::WorkScope::Active,
1174            &lists.active,
1175        );
1176    }
1177    if matches!(
1178        list_scope,
1179        crate::work::WorkListScope::Completed | crate::work::WorkListScope::All
1180    ) {
1181        append_work_section(
1182            &mut out,
1183            &group,
1184            &repo,
1185            crate::work::WorkScope::Completed,
1186            &lists.completed,
1187        );
1188    }
1189    Ok(out)
1190}
1191
1192fn append_work_section(
1193    out: &mut String,
1194    group: &str,
1195    repo: &str,
1196    scope: crate::work::WorkScope,
1197    docs: &[crate::work::WorkSummary],
1198) {
1199    let title = match scope {
1200        crate::work::WorkScope::Active => "Active Work Documents",
1201        crate::work::WorkScope::Completed => "Completed Work Documents",
1202    };
1203    out.push_str(&format!(">{title} ({})\n\n", docs.len()));
1204    if docs.is_empty() {
1205        out.push_str("No work documents found.\n\n");
1206        return;
1207    }
1208    for doc in docs {
1209        let doc_id = doc.id.to_string();
1210        out.push_str(&format!(
1211            "{} `F666{} updates`f\n",
1212            m_link(
1213                &format!("#{} {}", doc.id, doc.title),
1214                PATH_WORK_DOC,
1215                &[
1216                    ("g", group),
1217                    ("r", repo),
1218                    ("scope", scope.as_str()),
1219                    ("id", &doc_id)
1220                ]
1221            ),
1222            doc.comments
1223        ));
1224        out.push_str(&format!(
1225            "`F666{} by {}`f\n\n",
1226            format_unix_time(doc.created as i64),
1227            m_escape(&doc.author)
1228        ));
1229    }
1230}
1231
1232fn render_work_doc_page(
1233    config: &ServerConfig,
1234    access: &Access,
1235    remote: Option<&[u8; 16]>,
1236    vars: &BTreeMap<String, String>,
1237) -> Result<String> {
1238    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
1239    let scope = var(vars, "scope").unwrap_or("active");
1240    let scope = crate::work::WorkScope::parse(scope).ok_or_else(|| Error::msg("invalid scope"))?;
1241    let id = required_var(vars, "id")?
1242        .parse::<u64>()
1243        .map_err(|_| Error::msg("invalid work document ID"))?;
1244    let Some(document) =
1245        crate::work::view_document(&crate::work::work_sidecar_path(&repository), scope, id)?
1246    else {
1247        return Ok(
1248            ">Work Document Not Found\n\nThe requested work document was not found.\n".into(),
1249        );
1250    };
1251    let mut out = format!(
1252        ">>\n{} / {} / {} / {} / #{}\n\n>{}\n\n",
1253        m_link("Node", PATH_INDEX, &[]),
1254        m_link(&group, PATH_GROUP, &[("g", &group)]),
1255        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
1256        m_link(
1257            "work",
1258            PATH_WORK,
1259            &[("g", &group), ("r", &repo), ("scope", scope.as_str())]
1260        ),
1261        document.id,
1262        m_escape(&document.title)
1263    );
1264    out.push_str(&format!(
1265        "`F666Status: {} | Author: {} | Created: {} | Edited: {}`f\n\n",
1266        scope.as_str(),
1267        m_escape(&document.author),
1268        format_unix_time(document.created as i64),
1269        format_unix_time(document.edited as i64)
1270    ));
1271    append_formatted_content(&mut out, &document.format, &document.content);
1272    out.push_str(&format!("\n>Updates ({})\n\n", document.comments.len()));
1273    if document.comments.is_empty() {
1274        out.push_str("No updates for this work document.\n");
1275    } else {
1276        for comment in document.comments {
1277            out.push_str(&format!(
1278                ">#{} by {} at {}\n\n",
1279                comment.id,
1280                m_escape(&comment.author),
1281                format_unix_time(comment.created as i64)
1282            ));
1283            append_formatted_content(&mut out, &comment.format, &comment.content);
1284            out.push('\n');
1285        }
1286    }
1287    Ok(out)
1288}
1289
1290fn append_formatted_content(out: &mut String, format: &str, content: &str) {
1291    match format {
1292        "micron" => out.push_str(content),
1293        "markdown" => out.push_str(&markdown_to_micron(content)),
1294        _ => out.push_str(&m_escape(content)),
1295    }
1296    if !out.ends_with('\n') {
1297        out.push('\n');
1298    }
1299}
1300
1301pub fn download_file(
1302    config: &ServerConfig,
1303    access: &Access,
1304    data: &[u8],
1305    remote: Option<&[u8; 16]>,
1306) -> Result<RequestResponse> {
1307    let vars = parse_page_vars(data)?;
1308    let (_group, _repo, repository) = match accessible_repository(config, access, remote, &vars) {
1309        Ok(repo) => repo,
1310        Err(_) => {
1311            return Ok(RequestResponse::Bytes(protocol::status_bytes(
1312                protocol::RES_NOT_FOUND,
1313                b"repository not found",
1314            )));
1315        }
1316    };
1317    if let Some(artifact) = var(&vars, "artifact") {
1318        let artifact = decode_url_component(artifact);
1319        let tag = var(&vars, "tag").unwrap_or("latest");
1320        let releases_path = crate::release::release_sidecar_path(&repository);
1321        let Some(path) = crate::release::artifact_path(&releases_path, tag, &artifact)? else {
1322            return Ok(RequestResponse::Bytes(protocol::status_bytes(
1323                protocol::RES_NOT_FOUND,
1324                b"file not found",
1325            )));
1326        };
1327        return Ok(RequestResponse::Resource {
1328            data: fs::read(path)?,
1329            metadata: Some(protocol::metadata_status(protocol::RES_OK)),
1330            auto_compress: true,
1331        });
1332    }
1333
1334    let reference = var(&vars, "ref").unwrap_or("HEAD");
1335    let path = required_var(&vars, "path")?;
1336    validate_git_path(path)?;
1337    let resolved = resolve_ref(&repository, reference)?;
1338    let spec = format!("{resolved}:{path}");
1339    let output = run_git_output(
1340        Command::new("git")
1341            .arg("--git-dir")
1342            .arg(&repository)
1343            .arg("show")
1344            .arg(spec),
1345        GIT_COMMAND_TIMEOUT,
1346    )?;
1347    if !output.status.success() {
1348        return Ok(RequestResponse::Bytes(protocol::status_bytes(
1349            protocol::RES_NOT_FOUND,
1350            b"file not found",
1351        )));
1352    }
1353    Ok(RequestResponse::Resource {
1354        data: output.stdout,
1355        metadata: Some(protocol::metadata_status(protocol::RES_OK)),
1356        auto_compress: true,
1357    })
1358}
1359
1360fn render_chart(data: &[u64], labels: &[String; 2], color: &str, height: u64) -> String {
1361    if data.is_empty() || data.iter().all(|value| *value == 0) {
1362        return "No data available\n".to_string();
1363    }
1364    let max = data.iter().copied().max().unwrap_or(1).max(1);
1365    let mut out = format!("`F{color}Peak: {max}`f\n");
1366    for row in (1..=height).rev() {
1367        let threshold = (row - 1) as f64 / height as f64 * max as f64;
1368        out.push('│');
1369        for value in data {
1370            if *value as f64 > threshold {
1371                let block = if row as f64 >= height as f64 * 0.875 {
1372                    '█'
1373                } else if row as f64 >= height as f64 * 0.625 {
1374                    '▓'
1375                } else if row as f64 >= height as f64 * 0.375 {
1376                    '▒'
1377                } else {
1378                    '░'
1379                };
1380                out.push_str(&format!("`F{color}{block}`f"));
1381            } else {
1382                out.push(' ');
1383            }
1384        }
1385        out.push('\n');
1386    }
1387    out.push('└');
1388    for _ in data {
1389        out.push('─');
1390    }
1391    out.push_str("┘\n");
1392    out.push_str(&format!("`F666{:<12}`f", labels[0]));
1393    let chart_width = data.len() + 2;
1394    let spacing = chart_width.saturating_sub(24);
1395    out.push_str(&" ".repeat(spacing));
1396    out.push_str(&format!("`F666{:>12}`f\n", labels[1]));
1397    out
1398}
1399
1400fn render_combined_chart(
1401    views: &[u64],
1402    fetches: &[u64],
1403    pushes: &[u64],
1404    labels: &[String; 2],
1405    height: u64,
1406) -> String {
1407    if views.is_empty() {
1408        return "No data available\n".to_string();
1409    }
1410    let totals: Vec<u64> = views
1411        .iter()
1412        .zip(fetches.iter())
1413        .zip(pushes.iter())
1414        .map(|((view, fetch), push)| view + fetch + push)
1415        .collect();
1416    let max = totals.iter().copied().max().unwrap_or(1).max(1);
1417    let mut out = String::from("`F66d██`f Views  `F0a0██`f Fetches  `Faa0██`f Pushes\n\n");
1418    for row in (1..=height).rev() {
1419        let threshold = (row - 1) as f64 / height as f64 * max as f64;
1420        out.push('│');
1421        for i in 0..views.len() {
1422            let view = views[i] as f64;
1423            let fetch = fetches.get(i).copied().unwrap_or(0) as f64;
1424            let push = pushes.get(i).copied().unwrap_or(0) as f64;
1425            let total = view + fetch + push;
1426            if total > threshold {
1427                if push > 0.0 && threshold >= view + fetch {
1428                    out.push_str("`Faa0█`f");
1429                } else if fetch > 0.0 && threshold >= view {
1430                    out.push_str("`F0a0▓`f");
1431                } else if view > 0.0 {
1432                    out.push_str("`F66d░`f");
1433                } else {
1434                    out.push_str("`F666▒`f");
1435                }
1436            } else {
1437                out.push(' ');
1438            }
1439        }
1440        out.push('\n');
1441    }
1442    out.push('└');
1443    for _ in views {
1444        out.push('─');
1445    }
1446    out.push_str("┘\n");
1447    out.push_str(&format!("`F666{:<12}`f", labels[0]));
1448    let chart_width = views.len() + 2;
1449    let spacing = chart_width.saturating_sub(24);
1450    out.push_str(&" ".repeat(spacing));
1451    out.push_str(&format!("`F666{:>12}`f\n", labels[1]));
1452    out
1453}
1454
1455fn accessible_groups(
1456    config: &ServerConfig,
1457    access: &Access,
1458    remote: Option<&[u8; 16]>,
1459) -> Result<BTreeMap<String, Vec<String>>> {
1460    let mut groups = BTreeMap::new();
1461    if !config.repositories_dir.exists() {
1462        return Ok(groups);
1463    }
1464    for group_entry in fs::read_dir(&config.repositories_dir)? {
1465        let group_entry = group_entry?;
1466        if !group_entry.file_type()?.is_dir() {
1467            continue;
1468        }
1469        let group = group_entry.file_name().to_string_lossy().to_string();
1470        if validate_group_name(&group).is_err() {
1471            continue;
1472        }
1473        let repos = accessible_repositories(config, access, remote, &group)?;
1474        if !repos.is_empty() {
1475            groups.insert(group, repos);
1476        }
1477    }
1478    Ok(groups)
1479}
1480
1481fn accessible_repositories(
1482    config: &ServerConfig,
1483    access: &Access,
1484    remote: Option<&[u8; 16]>,
1485    group: &str,
1486) -> Result<Vec<String>> {
1487    validate_group_name(group)?;
1488    let group_dir = config.repositories_dir.join(group);
1489    let mut repos = Vec::new();
1490    if !group_dir.exists() {
1491        return Ok(repos);
1492    }
1493    for repo_entry in fs::read_dir(group_dir)? {
1494        let repo_entry = repo_entry?;
1495        if !repo_entry.file_type()?.is_dir() {
1496            continue;
1497        }
1498        let repo = repo_entry.file_name().to_string_lossy().to_string();
1499        let Ok(name) = repository_name(group, &repo) else {
1500            continue;
1501        };
1502        if crate::git::is_bare_repository(&config.repositories_dir.join(&name))
1503            && access.allows(Operation::Read, &name, remote)?
1504        {
1505            repos.push(repo);
1506        }
1507    }
1508    repos.sort();
1509    Ok(repos)
1510}
1511
1512fn accessible_repository(
1513    config: &ServerConfig,
1514    access: &Access,
1515    remote: Option<&[u8; 16]>,
1516    vars: &BTreeMap<String, String>,
1517) -> Result<(String, String, PathBuf)> {
1518    let group = required_var(vars, "g")?;
1519    let repo = required_var(vars, "r")?;
1520    let name = repository_name(group, repo)?;
1521    let path = config.repositories_dir.join(&name);
1522    if !crate::git::is_bare_repository(&path) || !access.allows(Operation::Read, &name, remote)? {
1523        return Err(Error::msg("repository not found"));
1524    }
1525    Ok((group.to_string(), repo.to_string(), path))
1526}
1527
1528fn required_var<'a>(vars: &'a BTreeMap<String, String>, key: &str) -> Result<&'a str> {
1529    var(vars, key).ok_or_else(|| Error::msg(format!("missing page variable {key}")))
1530}
1531
1532fn decode_url_component(value: &str) -> String {
1533    let bytes = value.as_bytes();
1534    let mut out = Vec::with_capacity(bytes.len());
1535    let mut i = 0;
1536    while i < bytes.len() {
1537        match bytes[i] {
1538            b'+' => {
1539                out.push(b' ');
1540                i += 1;
1541            }
1542            b'%' if i + 2 < bytes.len() => {
1543                if let (Some(high), Some(low)) = (hex_value(bytes[i + 1]), hex_value(bytes[i + 2]))
1544                {
1545                    out.push((high << 4) | low);
1546                    i += 3;
1547                } else {
1548                    out.push(bytes[i]);
1549                    i += 1;
1550                }
1551            }
1552            byte => {
1553                out.push(byte);
1554                i += 1;
1555            }
1556        }
1557    }
1558    String::from_utf8_lossy(&out).into_owned()
1559}
1560
1561fn hex_value(byte: u8) -> Option<u8> {
1562    match byte {
1563        b'0'..=b'9' => Some(byte - b'0'),
1564        b'a'..=b'f' => Some(byte - b'a' + 10),
1565        b'A'..=b'F' => Some(byte - b'A' + 10),
1566        _ => None,
1567    }
1568}
1569
1570fn var<'a>(vars: &'a BTreeMap<String, String>, key: &str) -> Option<&'a str> {
1571    vars.get(&format!("var_{key}"))
1572        .or_else(|| vars.get(key))
1573        .map(String::as_str)
1574}
1575
1576fn repository_name(group: &str, repo: &str) -> Result<String> {
1577    validate_group_name(group)?;
1578    validate_repo_component(repo)?;
1579    let name = format!("{group}/{repo}");
1580    validate_repo_name(&name)?;
1581    Ok(name)
1582}
1583
1584fn validate_group_name(group: &str) -> Result<()> {
1585    validate_repo_component(group)
1586}
1587
1588fn validate_repo_component(component: &str) -> Result<()> {
1589    if component.is_empty()
1590        || component == "."
1591        || component == ".."
1592        || component.contains('/')
1593        || component.contains('\\')
1594        || component.len() > 256
1595    {
1596        Err(Error::msg("invalid repository name"))
1597    } else {
1598        Ok(())
1599    }
1600}
1601
1602fn validate_git_path(path: &str) -> Result<()> {
1603    if path.starts_with('/') || path.contains('\\') {
1604        return Err(Error::msg("invalid repository path"));
1605    }
1606    for component in path.split('/') {
1607        if component == "." || component == ".." {
1608            return Err(Error::msg("invalid repository path"));
1609        }
1610    }
1611    Ok(())
1612}
1613
1614fn validate_refish(value: &str) -> Result<()> {
1615    if value.is_empty()
1616        || value.starts_with('-')
1617        || value.contains("..")
1618        || value.contains('\\')
1619        || value.contains(' ')
1620        || value.contains('~')
1621        || value.contains('^')
1622        || value.contains(':')
1623    {
1624        Err(Error::msg("invalid git ref"))
1625    } else {
1626        Ok(())
1627    }
1628}
1629
1630fn repository_description(repo: &Path) -> Result<String> {
1631    let path = repo.join("description");
1632    if !path.exists() {
1633        return Ok(String::new());
1634    }
1635    Ok(fs::read_to_string(path)?.trim().to_string())
1636}
1637
1638fn repository_thanks(repo: &Path, add: bool) -> Result<u64> {
1639    let path = repo.with_extension("thanks");
1640    let mut count = if path.exists() {
1641        fs::read_to_string(&path)?
1642            .trim()
1643            .parse::<u64>()
1644            .unwrap_or(0)
1645    } else {
1646        0
1647    };
1648    if add {
1649        count = count.saturating_add(1);
1650        fs::write(path, format!("{count}\n"))?;
1651    }
1652    Ok(count)
1653}
1654
1655#[derive(Debug)]
1656struct ReadmeContent {
1657    content: String,
1658    markdown: bool,
1659}
1660
1661fn readme_content(repo: &Path) -> Result<Option<ReadmeContent>> {
1662    const NAMES: &[&str] = &[
1663        "README.mu",
1664        "Readme.mu",
1665        "readme.mu",
1666        "README",
1667        "readme",
1668        "README.md",
1669        "readme.md",
1670        "README.rst",
1671        "README.txt",
1672        "readme.rst",
1673        "readme.txt",
1674    ];
1675    for name in NAMES {
1676        match run_git_output(
1677            Command::new("git")
1678                .arg("--git-dir")
1679                .arg(repo)
1680                .arg("show")
1681                .arg(format!("HEAD:{name}")),
1682            GIT_COMMAND_TIMEOUT,
1683        ) {
1684            Ok(output) if output.status.success() => {
1685                let content = String::from_utf8_lossy(&output.stdout).into_owned();
1686                let markdown = name.ends_with(".md");
1687                let content = if markdown || name.ends_with(".mu") {
1688                    content
1689                } else {
1690                    m_escape(&content)
1691                };
1692                return Ok(Some(ReadmeContent { content, markdown }));
1693            }
1694            Ok(_) => {}
1695            Err(err) => {
1696                log::warn!("Git command execution failed while reading README: {err}");
1697            }
1698        }
1699    }
1700    Ok(None)
1701}
1702
1703fn markdown_to_micron(input: &str) -> String {
1704    markdown_to_micron_scoped(input, None)
1705}
1706
1707fn markdown_to_micron_scoped(input: &str, url_scope: Option<&str>) -> String {
1708    let lines: Vec<&str> = input.lines().collect();
1709    let mut out = String::new();
1710    let mut code_block: Option<(Option<String>, String)> = None;
1711    let mut index = 0;
1712
1713    while index < lines.len() {
1714        let line = lines[index];
1715        let trimmed = line.trim();
1716        if trimmed.starts_with("```") {
1717            if let Some((language, content)) = code_block.take() {
1718                out.push_str(&crate::highlight::literal_block(
1719                    &content,
1720                    None,
1721                    language.as_deref(),
1722                ));
1723            } else {
1724                code_block = Some((markdown_fence_language(trimmed), String::new()));
1725            }
1726            index += 1;
1727            continue;
1728        }
1729        if let Some((_, content)) = code_block.as_mut() {
1730            content.push_str(line);
1731            content.push('\n');
1732            index += 1;
1733            continue;
1734        }
1735        if let Some(quote) = markdown_quote(line) {
1736            let mut parts = vec![quote.to_string()];
1737            index += 1;
1738            while index < lines.len() {
1739                if let Some(quote) = markdown_quote(lines[index]) {
1740                    parts.push(quote.to_string());
1741                    index += 1;
1742                } else {
1743                    break;
1744                }
1745            }
1746            let formatted = format_markdown_inline_scoped(&parts.join(" "), url_scope);
1747            for wrapped in markdown_wrap_text(&formatted, 77) {
1748                out.push_str(" │ ");
1749                out.push_str(&wrapped);
1750                out.push('\n');
1751            }
1752            continue;
1753        }
1754        if is_table_start(&lines, index) {
1755            let mut table = Vec::new();
1756            while index < lines.len() && lines[index].contains('|') {
1757                table.push(lines[index]);
1758                index += 1;
1759            }
1760            out.push_str(&format_markdown_table(&table, url_scope));
1761            continue;
1762        }
1763        if let Some((level, text)) = markdown_heading(line) {
1764            out.push_str(&">".repeat(level));
1765            out.push_str(&format_markdown_inline_scoped(text.trim(), url_scope));
1766            out.push('\n');
1767        } else if is_markdown_rule(trimmed) {
1768            out.push_str("-\n");
1769        } else if let Some((indent, text)) = unordered_list_item(line) {
1770            out.push_str(indent);
1771            out.push_str(" • ");
1772            out.push_str(&format_markdown_inline_scoped(text, url_scope));
1773            out.push('\n');
1774        } else if let Some((indent, number, text)) = ordered_list_item(line) {
1775            out.push_str(indent);
1776            out.push_str(number);
1777            out.push_str(". ");
1778            out.push_str(&format_markdown_inline_scoped(text, url_scope));
1779            out.push('\n');
1780        } else {
1781            out.push_str(&format_markdown_line(line, url_scope));
1782            out.push('\n');
1783        }
1784        index += 1;
1785    }
1786    if let Some((language, content)) = code_block {
1787        out.push_str(&crate::highlight::literal_block(
1788            &content,
1789            None,
1790            language.as_deref(),
1791        ));
1792    }
1793
1794    out
1795}
1796
1797fn markdown_fence_language(trimmed: &str) -> Option<String> {
1798    trimmed
1799        .strip_prefix("```")
1800        .and_then(|info| info.split_whitespace().next())
1801        .filter(|language| !language.is_empty())
1802        .map(str::to_string)
1803}
1804
1805fn markdown_quote(line: &str) -> Option<&str> {
1806    line.strip_prefix('>')
1807        .map(|quote| quote.strip_prefix(' ').unwrap_or(quote))
1808}
1809
1810fn markdown_heading(line: &str) -> Option<(usize, &str)> {
1811    let level = line.chars().take_while(|ch| *ch == '#').count();
1812    if (1..=6).contains(&level) && line.as_bytes().get(level) == Some(&b' ') {
1813        Some((level, &line[level + 1..]))
1814    } else {
1815        None
1816    }
1817}
1818
1819fn is_markdown_rule(trimmed: &str) -> bool {
1820    trimmed.len() >= 3
1821        && ["-", "*", "_", "="]
1822            .iter()
1823            .any(|marker| trimmed.chars().all(|ch| ch.to_string() == *marker))
1824}
1825
1826fn unordered_list_item(line: &str) -> Option<(&str, &str)> {
1827    let trimmed = line.trim_start();
1828    let indent = &line[..line.len() - trimmed.len()];
1829    for marker in ["- ", "* ", "+ "] {
1830        if let Some(text) = trimmed.strip_prefix(marker) {
1831            return Some((indent, text));
1832        }
1833    }
1834    None
1835}
1836
1837fn ordered_list_item(line: &str) -> Option<(&str, &str, &str)> {
1838    let trimmed = line.trim_start();
1839    let indent = &line[..line.len() - trimmed.len()];
1840    let Some((number, text)) = trimmed.split_once(". ") else {
1841        return None;
1842    };
1843    if !number.is_empty() && number.chars().all(|ch| ch.is_ascii_digit()) {
1844        Some((indent, number, text))
1845    } else {
1846        None
1847    }
1848}
1849
1850fn format_markdown_line(line: &str, url_scope: Option<&str>) -> String {
1851    let formatted = format_markdown_inline_scoped(line, url_scope);
1852    if (line.starts_with('-') && !line.starts_with("---") && !line.starts_with("- "))
1853        || line.starts_with('<')
1854    {
1855        format!("\\{formatted}")
1856    } else {
1857        formatted
1858    }
1859}
1860
1861fn is_table_start(lines: &[&str], index: usize) -> bool {
1862    index + 1 < lines.len()
1863        && lines[index].contains('|')
1864        && lines[index + 1].contains('|')
1865        && is_table_separator(lines[index + 1])
1866}
1867
1868fn is_table_separator(line: &str) -> bool {
1869    parse_table_row(line)
1870        .iter()
1871        .all(|cell| !cell.is_empty() && cell.chars().all(|ch| matches!(ch, '-' | ':' | ' ')))
1872}
1873
1874#[derive(Clone, Copy)]
1875enum TableAlign {
1876    Left,
1877    Right,
1878    Center,
1879}
1880
1881fn format_markdown_table(lines: &[&str], url_scope: Option<&str>) -> String {
1882    if lines.len() < 2 {
1883        return String::new();
1884    }
1885    let header: Vec<String> = parse_table_row(lines[0])
1886        .into_iter()
1887        .map(|cell| format_markdown_inline_scoped(cell.trim(), url_scope))
1888        .collect();
1889    if header.is_empty() {
1890        return String::new();
1891    }
1892    let columns = header.len();
1893    let mut alignments = parse_table_alignments(lines[1]);
1894    while alignments.len() < columns {
1895        alignments.push(TableAlign::Left);
1896    }
1897    alignments.truncate(columns);
1898
1899    let rows: Vec<Vec<String>> = lines
1900        .iter()
1901        .skip(2)
1902        .map(|line| {
1903            let mut row: Vec<String> = parse_table_row(line)
1904                .into_iter()
1905                .map(|cell| format_markdown_inline_scoped(cell.trim(), url_scope))
1906                .collect();
1907            while row.len() < columns {
1908                row.push(String::new());
1909            }
1910            row.truncate(columns);
1911            row
1912        })
1913        .collect();
1914
1915    let mut widths = vec![3; columns];
1916    for (index, cell) in header.iter().enumerate() {
1917        widths[index] = widths[index].max(markdown_visible_width(cell));
1918    }
1919    for row in &rows {
1920        for (index, cell) in row.iter().enumerate().take(columns) {
1921            widths[index] = widths[index].max(markdown_visible_width(cell));
1922        }
1923    }
1924
1925    let mut out = String::new();
1926    out.push_str(&table_border('┌', '┬', '┐', &widths));
1927    out.push_str(&table_row(
1928        &header,
1929        &widths,
1930        &vec![TableAlign::Left; columns],
1931    ));
1932    out.push_str(&table_border('├', '┼', '┤', &widths));
1933    for row in rows {
1934        out.push_str(&table_row(&row, &widths, &alignments));
1935    }
1936    out.push_str(&table_border('└', '┴', '┘', &widths));
1937    out
1938}
1939
1940fn parse_table_row(line: &str) -> Vec<String> {
1941    let mut trimmed = line.trim();
1942    if let Some(rest) = trimmed.strip_prefix('|') {
1943        trimmed = rest;
1944    }
1945    if let Some(rest) = trimmed.strip_suffix('|') {
1946        trimmed = rest;
1947    }
1948    let mut cells = Vec::new();
1949    let mut current = String::new();
1950    let mut escaped = false;
1951    for ch in trimmed.chars() {
1952        if escaped {
1953            current.push(ch);
1954            escaped = false;
1955        } else if ch == '\\' {
1956            escaped = true;
1957        } else if ch == '|' {
1958            cells.push(current.trim().to_string());
1959            current.clear();
1960        } else {
1961            current.push(ch);
1962        }
1963    }
1964    if escaped {
1965        current.push('\\');
1966    }
1967    cells.push(current.trim().to_string());
1968    cells
1969}
1970
1971fn parse_table_alignments(line: &str) -> Vec<TableAlign> {
1972    parse_table_row(line)
1973        .into_iter()
1974        .map(|cell| {
1975            let cell = cell.trim();
1976            if cell.starts_with(':') && cell.ends_with(':') {
1977                TableAlign::Center
1978            } else if cell.ends_with(':') {
1979                TableAlign::Right
1980            } else {
1981                TableAlign::Left
1982            }
1983        })
1984        .collect()
1985}
1986
1987fn table_border(left: char, middle: char, right: char, widths: &[usize]) -> String {
1988    let mut out = String::new();
1989    out.push(left);
1990    for (index, width) in widths.iter().enumerate() {
1991        out.push_str(&"─".repeat(width + 2));
1992        if index + 1 == widths.len() {
1993            out.push(right);
1994        } else {
1995            out.push(middle);
1996        }
1997    }
1998    out.push('\n');
1999    out
2000}
2001
2002fn table_row(cells: &[String], widths: &[usize], alignments: &[TableAlign]) -> String {
2003    let mut out = String::from("│");
2004    for (index, width) in widths.iter().enumerate() {
2005        let cell = cells.get(index).map(String::as_str).unwrap_or("");
2006        out.push(' ');
2007        out.push_str(&pad_table_cell(
2008            cell,
2009            *width,
2010            alignments.get(index).copied().unwrap_or(TableAlign::Left),
2011        ));
2012        out.push(' ');
2013        out.push('│');
2014    }
2015    out.push('\n');
2016    out
2017}
2018
2019fn pad_table_cell(cell: &str, width: usize, alignment: TableAlign) -> String {
2020    let visible = markdown_visible_width(cell);
2021    let padding = width.saturating_sub(visible);
2022    match alignment {
2023        TableAlign::Left => format!("{cell}{}", " ".repeat(padding)),
2024        TableAlign::Right => format!("{}{cell}", " ".repeat(padding)),
2025        TableAlign::Center => {
2026            let left = padding / 2;
2027            let right = padding - left;
2028            format!("{}{cell}{}", " ".repeat(left), " ".repeat(right))
2029        }
2030    }
2031}
2032
2033fn markdown_visible_width(value: &str) -> usize {
2034    let mut width = 0;
2035    let mut chars = value.chars().peekable();
2036    while let Some(ch) = chars.next() {
2037        if ch == '`' {
2038            if matches!(chars.peek(), Some('F') | Some('B')) {
2039                chars.next();
2040                if matches!(chars.peek(), Some('T')) {
2041                    chars.next();
2042                    for _ in 0..6 {
2043                        chars.next();
2044                    }
2045                } else {
2046                    for _ in 0..3 {
2047                        chars.next();
2048                    }
2049                }
2050                continue;
2051            }
2052            if matches!(chars.peek(), Some('!') | Some('*') | Some('_') | Some('=')) {
2053                chars.next();
2054                continue;
2055            }
2056            if matches!(chars.peek(), Some('f') | Some('b') | Some('a')) {
2057                chars.next();
2058                continue;
2059            }
2060            if matches!(chars.peek(), Some('[')) {
2061                let mut label_width = 0;
2062                for link_ch in chars.by_ref() {
2063                    if link_ch == '`' {
2064                        for target_ch in chars.by_ref() {
2065                            if target_ch == ']' {
2066                                break;
2067                            }
2068                        }
2069                        break;
2070                    }
2071                    label_width += 1;
2072                }
2073                width += label_width;
2074                continue;
2075            }
2076        }
2077        width += 1;
2078    }
2079    width
2080}
2081
2082fn markdown_wrap_text(text: &str, width: usize) -> Vec<String> {
2083    if text.is_empty() {
2084        return vec![String::new()];
2085    }
2086    let mut lines = Vec::new();
2087    let mut current = String::new();
2088    let mut current_width = 0;
2089    for word in text.split_whitespace() {
2090        let word_width = markdown_visible_width(word);
2091        if !current.is_empty() && current_width + 1 + word_width > width {
2092            lines.push(current);
2093            current = String::new();
2094            current_width = 0;
2095        }
2096        if !current.is_empty() {
2097            current.push(' ');
2098            current_width += 1;
2099        }
2100        current.push_str(word);
2101        current_width += word_width;
2102    }
2103    if !current.is_empty() {
2104        lines.push(current);
2105    }
2106    if lines.is_empty() {
2107        lines.push(String::new());
2108    }
2109    lines
2110}
2111
2112#[derive(Debug)]
2113enum InlineToken {
2114    Link { label: String, target: String },
2115    Code(String),
2116}
2117
2118fn format_markdown_inline_scoped(input: &str, url_scope: Option<&str>) -> String {
2119    let (with_placeholders, tokens) = extract_markdown_inline_tokens(input);
2120    let escaped = m_escape(&with_placeholders);
2121    let styled = apply_markdown_style(&escaped, "**", "`!", "`!");
2122    let styled = apply_markdown_style(&styled, "__", "`!", "`!");
2123    let styled = apply_markdown_style(&styled, "*", "`*", "`*");
2124    let styled = apply_markdown_style(&styled, "_", "`*", "`*");
2125    restore_markdown_inline_tokens(&styled, &tokens, url_scope)
2126}
2127
2128fn extract_markdown_inline_tokens(input: &str) -> (String, Vec<InlineToken>) {
2129    let mut out = String::new();
2130    let mut tokens = Vec::new();
2131    let mut index = 0;
2132
2133    while index < input.len() {
2134        let rest = &input[index..];
2135        if rest.starts_with('`') {
2136            if let Some(end) = rest[1..].find('`') {
2137                let content = &rest[1..1 + end];
2138                if is_markdown_inline_code(content) {
2139                    tokens.push(InlineToken::Code(content.to_string()));
2140                    out.push_str(&markdown_token_placeholder(tokens.len() - 1));
2141                    index += end + 2;
2142                    continue;
2143                }
2144            }
2145        }
2146        if rest.starts_with('[') {
2147            if let Some((consumed, label, target)) = parse_markdown_link(rest) {
2148                tokens.push(InlineToken::Link { label, target });
2149                out.push_str(&markdown_token_placeholder(tokens.len() - 1));
2150                index += consumed;
2151                continue;
2152            }
2153        }
2154        let Some(ch) = rest.chars().next() else {
2155            break;
2156        };
2157        out.push(ch);
2158        index += ch.len_utf8();
2159    }
2160
2161    (out, tokens)
2162}
2163
2164fn is_markdown_inline_code(content: &str) -> bool {
2165    !content.is_empty()
2166        && !content
2167            .chars()
2168            .next()
2169            .is_some_and(|ch| matches!(ch, '!' | '*' | '_' | '=' | 'F' | 'B' | 'f' | 'b' | 'a'))
2170}
2171
2172fn parse_markdown_link(input: &str) -> Option<(usize, String, String)> {
2173    let label_end = input[1..].find(']')? + 1;
2174    if !input[label_end + 1..].starts_with('(') {
2175        return None;
2176    }
2177    let target_start = label_end + 2;
2178    let target_end = input[target_start..].find(')')? + target_start;
2179    Some((
2180        target_end + 1,
2181        input[1..label_end].replace('`', ""),
2182        input[target_start..target_end].to_string(),
2183    ))
2184}
2185
2186fn markdown_token_placeholder(index: usize) -> String {
2187    format!("\u{1f}{index}\u{1f}")
2188}
2189
2190fn restore_markdown_inline_tokens(
2191    input: &str,
2192    tokens: &[InlineToken],
2193    url_scope: Option<&str>,
2194) -> String {
2195    let mut out = input.to_string();
2196    for (index, token) in tokens.iter().enumerate() {
2197        let replacement = match token {
2198            InlineToken::Link { label, target } => format!(
2199                "`!`[{}{}{}]`!",
2200                sanitize_label(label).trim(),
2201                "`",
2202                markdown_link_target(target, url_scope)
2203            ),
2204            InlineToken::Code(value) => format!("`BT383838`Fddd{}`f`b", m_escape(value)),
2205        };
2206        out = out.replace(&markdown_token_placeholder(index), &replacement);
2207    }
2208    out
2209}
2210
2211fn markdown_link_target(value: &str, url_scope: Option<&str>) -> String {
2212    if let Some(scope) = url_scope {
2213        if !value.contains(":/") {
2214            let (path, anchor) = value
2215                .split_once('#')
2216                .map(|(path, anchor)| (path, Some(anchor)))
2217                .unwrap_or((value, None));
2218            let mut out = format!("{scope}{}", sanitize_field(path));
2219            if let Some(anchor) = anchor.filter(|anchor| !anchor.is_empty()) {
2220                out.push_str("|anchor=");
2221                out.push_str(&sanitize_field(anchor));
2222            }
2223            return out;
2224        }
2225    }
2226    sanitize_markdown_link_target(value)
2227}
2228
2229fn sanitize_markdown_link_target(value: &str) -> String {
2230    value
2231        .chars()
2232        .filter(|ch| *ch != '[' && *ch != ']' && *ch != '`')
2233        .collect()
2234}
2235
2236fn apply_markdown_style(input: &str, marker: &str, open: &str, close: &str) -> String {
2237    let mut out = String::new();
2238    let mut rest = input;
2239
2240    while let Some(start) = rest.find(marker) {
2241        let content_start = start + marker.len();
2242        let Some(end) = rest[content_start..].find(marker) else {
2243            out.push_str(rest);
2244            return out;
2245        };
2246        let content_end = content_start + end;
2247        out.push_str(&rest[..start]);
2248        out.push_str(open);
2249        out.push_str(&rest[content_start..content_end]);
2250        out.push_str(close);
2251        rest = &rest[content_end + marker.len()..];
2252    }
2253    out.push_str(rest);
2254    out
2255}
2256
2257#[derive(Debug)]
2258struct Refs {
2259    heads: Vec<RefInfo>,
2260    tags: Vec<RefInfo>,
2261}
2262
2263#[derive(Debug)]
2264struct RefInfo {
2265    sha: String,
2266    name: String,
2267}
2268
2269fn git_refs(repo: &Path) -> Result<Refs> {
2270    let output = run_git(
2271        Command::new("git")
2272            .arg("--git-dir")
2273            .arg(repo)
2274            .args(["for-each-ref", "--format=%(objectname) %(refname)"]),
2275    )?;
2276    let mut refs = Refs {
2277        heads: Vec::new(),
2278        tags: Vec::new(),
2279    };
2280    for line in output.lines() {
2281        let Some((sha, name)) = line.split_once(' ') else {
2282            continue;
2283        };
2284        if let Some(short) = name.strip_prefix("refs/heads/") {
2285            refs.heads.push(RefInfo {
2286                sha: sha.to_string(),
2287                name: short.to_string(),
2288            });
2289        } else if let Some(short) = name.strip_prefix("refs/tags/") {
2290            refs.tags.push(RefInfo {
2291                sha: sha.to_string(),
2292                name: short.to_string(),
2293            });
2294        }
2295    }
2296    Ok(refs)
2297}
2298
2299fn resolve_ref(repo: &Path, reference: &str) -> Result<String> {
2300    validate_refish(reference)?;
2301    let output = run_git(
2302        Command::new("git")
2303            .arg("--git-dir")
2304            .arg(repo)
2305            .arg("rev-parse")
2306            .arg("--verify")
2307            .arg(format!("{reference}^{{commit}}")),
2308    )?;
2309    Ok(output.trim().to_string())
2310}
2311
2312#[derive(Debug)]
2313struct TreeEntry {
2314    kind: String,
2315    name: String,
2316    size: Option<u64>,
2317}
2318
2319impl TreeEntry {
2320    fn size_label(&self) -> String {
2321        self.size
2322            .map(|size| format!(" ({size} bytes)"))
2323            .unwrap_or_default()
2324    }
2325}
2326
2327fn tree_entries(repo: &Path, reference: &str, path: &str) -> Result<Vec<TreeEntry>> {
2328    validate_git_path(path)?;
2329    let spec = if path.is_empty() {
2330        reference.to_string()
2331    } else {
2332        format!("{reference}:{path}")
2333    };
2334    let output = run_git(
2335        Command::new("git")
2336            .arg("--git-dir")
2337            .arg(repo)
2338            .arg("ls-tree")
2339            .arg("-l")
2340            .arg(spec),
2341    )?;
2342    let mut entries = Vec::new();
2343    for line in output.lines() {
2344        let Some((meta, name)) = line.split_once('\t') else {
2345            continue;
2346        };
2347        let parts: Vec<&str> = meta.split_whitespace().collect();
2348        if parts.len() < 4 {
2349            continue;
2350        }
2351        let size = parts[3].parse::<u64>().ok();
2352        entries.push(TreeEntry {
2353            kind: parts[1].to_string(),
2354            name: name.to_string(),
2355            size,
2356        });
2357    }
2358    entries.sort_by_key(|entry| (entry.kind != "tree", entry.name.to_ascii_lowercase()));
2359    Ok(entries)
2360}
2361
2362struct BlobInfo {
2363    content: String,
2364    size: u64,
2365    binary: bool,
2366    displayable: bool,
2367}
2368
2369fn blob_info(repo: &Path, reference: &str, path: &str) -> Result<BlobInfo> {
2370    validate_git_path(path)?;
2371    let spec = format!("{reference}:{path}");
2372    let size = run_git(
2373        Command::new("git")
2374            .arg("--git-dir")
2375            .arg(repo)
2376            .arg("cat-file")
2377            .arg("-s")
2378            .arg(&spec),
2379    )?
2380    .trim()
2381    .parse::<u64>()
2382    .unwrap_or(0);
2383    if size > 256 * 1024 {
2384        return Ok(BlobInfo {
2385            content: format!("File is too large to display ({size} bytes)."),
2386            size,
2387            binary: false,
2388            displayable: false,
2389        });
2390    }
2391    let output = run_git_output(
2392        Command::new("git")
2393            .arg("--git-dir")
2394            .arg(repo)
2395            .arg("show")
2396            .arg(spec),
2397        GIT_COMMAND_TIMEOUT,
2398    )?;
2399    if !output.status.success() {
2400        return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
2401    }
2402    let binary = output.stdout.contains(&0);
2403    Ok(BlobInfo {
2404        content: if binary {
2405            "Binary file is not displayed.".to_string()
2406        } else {
2407            String::from_utf8_lossy(&output.stdout).into_owned()
2408        },
2409        size,
2410        binary,
2411        displayable: !binary,
2412    })
2413}
2414
2415#[derive(Debug)]
2416struct CommitSummary {
2417    hash: String,
2418    subject: String,
2419    author: String,
2420    timestamp: i64,
2421}
2422
2423fn commits(repo: &Path, reference: &str, limit: usize) -> Result<Vec<CommitSummary>> {
2424    let format = "%H%x1f%s%x1f%an%x1f%at";
2425    let output = run_git(
2426        Command::new("git")
2427            .arg("--git-dir")
2428            .arg(repo)
2429            .arg("log")
2430            .arg(format!("--format={format}"))
2431            .arg("-n")
2432            .arg(limit.to_string())
2433            .arg(reference),
2434    )?;
2435    let mut commits = Vec::new();
2436    for line in output.lines() {
2437        let parts: Vec<&str> = line.split('\x1f').collect();
2438        if parts.len() == 4 {
2439            commits.push(CommitSummary {
2440                hash: parts[0].to_string(),
2441                subject: parts[1].to_string(),
2442                author: parts[2].to_string(),
2443                timestamp: parts[3].parse().unwrap_or_default(),
2444            });
2445        }
2446    }
2447    Ok(commits)
2448}
2449
2450#[derive(Debug)]
2451struct CommitInfo {
2452    hash: String,
2453    subject: String,
2454    body: String,
2455    author: String,
2456    email: String,
2457    timestamp: i64,
2458    files: Vec<CommitFile>,
2459}
2460
2461#[derive(Debug)]
2462struct CommitFile {
2463    status: String,
2464    path: String,
2465}
2466
2467fn commit_info(repo: &Path, hash: &str) -> Result<CommitInfo> {
2468    validate_refish(hash)?;
2469    let output = run_git(
2470        Command::new("git")
2471            .arg("--git-dir")
2472            .arg(repo)
2473            .arg("show")
2474            .arg("--no-patch")
2475            .arg("--format=%H%x1f%s%x1f%an%x1f%ae%x1f%at%x1f%B")
2476            .arg(hash),
2477    )?;
2478    let parts: Vec<&str> = output.splitn(6, '\x1f').collect();
2479    if parts.len() != 6 {
2480        return Err(Error::msg("invalid commit output"));
2481    }
2482    let files_output = run_git(
2483        Command::new("git")
2484            .arg("--git-dir")
2485            .arg(repo)
2486            .arg("diff-tree")
2487            .arg("--root")
2488            .arg("--no-commit-id")
2489            .arg("--name-status")
2490            .arg("-r")
2491            .arg(hash),
2492    )?;
2493    let mut files = Vec::new();
2494    for line in files_output.lines() {
2495        let Some((status, path)) = line.split_once('\t') else {
2496            continue;
2497        };
2498        files.push(CommitFile {
2499            status: status.to_string(),
2500            path: path.to_string(),
2501        });
2502    }
2503    Ok(CommitInfo {
2504        hash: parts[0].trim().to_string(),
2505        subject: parts[1].trim().to_string(),
2506        author: parts[2].trim().to_string(),
2507        email: parts[3].trim().to_string(),
2508        timestamp: parts[4].trim().parse().unwrap_or_default(),
2509        body: parts[5].trim().to_string(),
2510        files,
2511    })
2512}
2513
2514fn run_git(cmd: &mut Command) -> Result<String> {
2515    let output = run_git_output(cmd, GIT_COMMAND_TIMEOUT)?;
2516    if !output.status.success() {
2517        return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
2518    }
2519    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
2520}
2521
2522fn run_git_output(cmd: &mut Command, timeout: Duration) -> Result<Output> {
2523    let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
2524    let deadline = Instant::now() + timeout;
2525
2526    loop {
2527        if child.try_wait()?.is_some() {
2528            return child.wait_with_output().map_err(Error::from);
2529        }
2530
2531        if Instant::now() >= deadline {
2532            let _ = child.kill();
2533            let _ = child.wait();
2534            log::warn!("Git command execution timed out");
2535            return Err(Error::msg("Git command execution timed out"));
2536        }
2537
2538        let sleep_for = deadline
2539            .saturating_duration_since(Instant::now())
2540            .min(Duration::from_millis(10));
2541        if !sleep_for.is_zero() {
2542            thread::sleep(sleep_for);
2543        }
2544    }
2545}
2546
2547#[cfg(test)]
2548mod tests {
2549    use super::*;
2550    use crate::acl::Access;
2551    use crate::config::ServerConfig;
2552    use crate::logging;
2553    use rns_core::msgpack::{self, Value};
2554    use rns_crypto::OsRng;
2555    use std::fs;
2556    use std::process::Command;
2557
2558    #[test]
2559    fn nomadnet_destination_uses_upstream_name() {
2560        let identity = Identity::new(&mut OsRng);
2561        let destination = destination_for_identity(&identity);
2562        let expected =
2563            Destination::single_in("nomadnetwork", &["node"], IdentityHash(*identity.hash()));
2564        assert_eq!(destination.hash, expected.hash);
2565    }
2566
2567    #[test]
2568    fn git_command_timeout_kills_slow_process() {
2569        let start = Instant::now();
2570
2571        let err =
2572            run_git_output(Command::new("sleep").arg("5"), Duration::from_millis(30)).unwrap_err();
2573
2574        assert!(start.elapsed() < Duration::from_secs(1));
2575        assert!(err.to_string().contains("timed out"));
2576    }
2577
2578    #[test]
2579    fn front_page_lists_only_readable_groups() {
2580        let tmp = tempfile::tempdir().unwrap();
2581        let mut config = cfg(tmp.path());
2582        config.allow_read = vec!["none".into()];
2583        create_repo(
2584            config.repositories_dir.join("public/alpha"),
2585            "README.md",
2586            "alpha readme\n",
2587        );
2588        create_repo(
2589            config.repositories_dir.join("private/secret"),
2590            "README.md",
2591            "secret\n",
2592        );
2593        fs::write(
2594            config.repositories_dir.join("public/group.allowed"),
2595            "read = all\n",
2596        )
2597        .unwrap();
2598        fs::write(
2599            config.repositories_dir.join("private/group.allowed"),
2600            "read = none\n",
2601        )
2602        .unwrap();
2603        let access = access(&config);
2604
2605        let page = render_page(PATH_INDEX, &config, &access, &page_request(&[]), None).unwrap();
2606        assert!(page.contains("public"));
2607        assert!(page.contains("alpha"));
2608        assert!(!page.contains("private"));
2609        assert!(!page.contains("secret"));
2610    }
2611
2612    #[test]
2613    fn group_and_repo_pages_use_repository_metadata() {
2614        let tmp = tempfile::tempdir().unwrap();
2615        let config = cfg(tmp.path());
2616        let repo = create_repo(
2617            config.repositories_dir.join("public/alpha"),
2618            "README.md",
2619            "# Alpha\n",
2620        );
2621        fs::write(repo.join("description"), "Alpha repository\n").unwrap();
2622        let access = access(&config);
2623
2624        let group = render_page(
2625            PATH_GROUP,
2626            &config,
2627            &access,
2628            &page_request(&[("var_g", "public")]),
2629            None,
2630        )
2631        .unwrap();
2632        assert!(group.contains("Alpha repository"));
2633        assert!(group.contains("alpha"));
2634
2635        let repo = render_page(
2636            PATH_REPO,
2637            &config,
2638            &access,
2639            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
2640            None,
2641        )
2642        .unwrap();
2643        assert!(repo.contains("rns://"));
2644        assert!(repo.contains("Alpha repository"));
2645        assert!(repo.contains(">Alpha"));
2646    }
2647
2648    #[test]
2649    fn tree_blob_commits_commit_and_refs_pages_are_git_backed() {
2650        let tmp = tempfile::tempdir().unwrap();
2651        let config = cfg(tmp.path());
2652        let repo = create_repo(
2653            config.repositories_dir.join("public/alpha"),
2654            "src/lib.rs",
2655            "pub fn answer() -> u8 { 42 }\n",
2656        );
2657        run_git(Command::new("git").arg("--git-dir").arg(&repo).args([
2658            "tag",
2659            "v1",
2660            "refs/heads/main",
2661        ]));
2662        let access = access(&config);
2663
2664        let tree = render_page(
2665            PATH_TREE,
2666            &config,
2667            &access,
2668            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
2669            None,
2670        )
2671        .unwrap();
2672        assert!(tree.contains("src/"));
2673
2674        let blob = render_page(
2675            PATH_BLOB,
2676            &config,
2677            &access,
2678            &page_request(&[
2679                ("var_g", "public"),
2680                ("var_r", "alpha"),
2681                ("var_path", "src/lib.rs"),
2682            ]),
2683            None,
2684        )
2685        .unwrap();
2686        assert!(blob.contains("answer"));
2687        #[cfg(feature = "syntax-highlighting")]
2688        assert!(blob.contains("`FT"));
2689        #[cfg(not(feature = "syntax-highlighting"))]
2690        assert!(blob.contains("pub fn answer()"));
2691
2692        let commits = render_page(
2693            PATH_COMMITS,
2694            &config,
2695            &access,
2696            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
2697            None,
2698        )
2699        .unwrap();
2700        assert!(commits.contains("initial"));
2701
2702        let commit_hash = run_git(
2703            Command::new("git")
2704                .arg("--git-dir")
2705                .arg(&repo)
2706                .args(["rev-parse", "refs/heads/main"]),
2707        );
2708        let commit = render_page(
2709            PATH_COMMIT,
2710            &config,
2711            &access,
2712            &page_request(&[
2713                ("var_g", "public"),
2714                ("var_r", "alpha"),
2715                ("var_h", commit_hash.trim()),
2716            ]),
2717            None,
2718        )
2719        .unwrap();
2720        assert!(commit.contains("initial"));
2721        assert!(commit.contains("src/lib.rs"));
2722
2723        let refs = render_page(
2724            PATH_REFS,
2725            &config,
2726            &access,
2727            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
2728            None,
2729        )
2730        .unwrap();
2731        assert!(refs.contains("main"));
2732        assert!(refs.contains("v1"));
2733    }
2734
2735    #[test]
2736    fn page_requests_reject_invalid_repository_names() {
2737        let tmp = tempfile::tempdir().unwrap();
2738        let config = cfg(tmp.path());
2739        let access = access(&config);
2740        let err = render_page(
2741            PATH_REPO,
2742            &config,
2743            &access,
2744            &page_request(&[("var_g", ".."), ("var_r", "repo")]),
2745            None,
2746        )
2747        .unwrap_err();
2748        assert!(err.to_string().contains("invalid repository name"));
2749    }
2750
2751    #[test]
2752    fn page_ref_resolution_rejects_invalid_refs_before_git_lookup() {
2753        let tmp = tempfile::tempdir().unwrap();
2754        let config = cfg(tmp.path());
2755        create_repo(
2756            config.repositories_dir.join("public/alpha"),
2757            "README.md",
2758            "# Alpha\n",
2759        );
2760        let access = access(&config);
2761
2762        for (path, vars) in [
2763            (
2764                PATH_TREE,
2765                vec![
2766                    ("var_g", "public"),
2767                    ("var_r", "alpha"),
2768                    ("var_ref", "--upload-pack=/tmp/x"),
2769                ],
2770            ),
2771            (
2772                PATH_BLOB,
2773                vec![
2774                    ("var_g", "public"),
2775                    ("var_r", "alpha"),
2776                    ("var_path", "README.md"),
2777                    ("var_ref", "refs/heads/main:README.md"),
2778                ],
2779            ),
2780            (
2781                PATH_COMMITS,
2782                vec![
2783                    ("var_g", "public"),
2784                    ("var_r", "alpha"),
2785                    ("var_ref", "refs/heads/main..refs/heads/other"),
2786                ],
2787            ),
2788        ] {
2789            let err = render_page(path, &config, &access, &page_request(&vars), None).unwrap_err();
2790            assert_eq!(err.to_string(), "invalid git ref");
2791        }
2792    }
2793
2794    #[test]
2795    fn pages_use_base_template_breadcrumbs_and_micron_links() {
2796        let tmp = tempfile::tempdir().unwrap();
2797        let config = cfg(tmp.path());
2798        create_repo(
2799            config.repositories_dir.join("public/alpha"),
2800            "README.md",
2801            "# Alpha\n",
2802        );
2803        let access = access(&config);
2804
2805        let repo = render_page(
2806            PATH_REPO,
2807            &config,
2808            &access,
2809            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
2810            None,
2811        )
2812        .unwrap();
2813
2814        assert!(repo.starts_with("#!c=0\n>Test Git Node\n\n>>\n"));
2815        assert!(repo.contains("`!`[Node`:/page/index.mu]`!"));
2816        assert!(repo.contains("`!`[public`:/page/group.mu`g=public]`!"));
2817        assert!(repo.contains("`[Files`:/page/tree.mu`g=public|r=alpha|ref=HEAD]"));
2818        assert!(repo.contains("`[Commits"));
2819        assert!(repo.contains("`[Branches (1)`:/page/refs.mu`g=public|r=alpha|type=heads]"));
2820        assert!(repo.contains("`[Tags (0)`:/page/refs.mu`g=public|r=alpha|type=tags]"));
2821        assert!(repo.contains("<\n-\n`a`F666`[Served by rngit`:/page/index.mu]"));
2822    }
2823
2824    #[test]
2825    fn custom_templates_wrap_page_content_and_replace_variables() {
2826        let tmp = tempfile::tempdir().unwrap();
2827        let config = cfg(tmp.path());
2828        fs::create_dir_all(&config.templates_dir).unwrap();
2829        fs::write(
2830            config.templates_dir.join("base.mu"),
2831            "BASE {NODE_NAME} {VERSION} {GEN_TIME}\n{PAGE_CONTENT}\n{NAVIGATION}   \n",
2832        )
2833        .unwrap();
2834        fs::write(
2835            config.templates_dir.join("repo.mu"),
2836            "REPO-BEGIN\n{PAGE_CONTENT}\nREPO-END   \n",
2837        )
2838        .unwrap();
2839        create_repo(
2840            config.repositories_dir.join("public/alpha"),
2841            "README.md",
2842            "# Alpha\n",
2843        );
2844        let access = access(&config);
2845
2846        let repo = render_page(
2847            PATH_REPO,
2848            &config,
2849            &access,
2850            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
2851            None,
2852        )
2853        .unwrap();
2854
2855        assert!(repo.starts_with("BASE Test Git Node "));
2856        assert!(repo.contains("\nREPO-BEGIN\n"));
2857        assert!(repo.contains(">Alpha"));
2858        assert!(repo.contains("\nREPO-END\n"));
2859        assert!(repo.contains(env!("CARGO_PKG_VERSION")));
2860        assert!(repo.contains("0ms"));
2861        assert!(!repo.ends_with("   \n"));
2862    }
2863
2864    #[test]
2865    fn template_substitution_does_not_rewrite_page_content_placeholders() {
2866        let tmp = tempfile::tempdir().unwrap();
2867        let config = cfg(tmp.path());
2868
2869        let out = apply_template(
2870            "Template {NODE_NAME}\n{PAGE_CONTENT}",
2871            "Literal {NODE_NAME}",
2872            &config,
2873        );
2874
2875        assert!(out.starts_with("Template Test Git Node\n"));
2876        assert!(out.ends_with("Literal {NODE_NAME}"));
2877    }
2878
2879    #[test]
2880    fn unicode_icon_config_changes_repository_navigation_labels() {
2881        let tmp = tempfile::tempdir().unwrap();
2882        let mut config = cfg(tmp.path());
2883        config.unicode_icons = true;
2884        create_repo(
2885            config.repositories_dir.join("public/alpha"),
2886            "README.md",
2887            "# Alpha\n",
2888        );
2889        let access = access(&config);
2890
2891        let repo = render_page(
2892            PATH_REPO,
2893            &config,
2894            &access,
2895            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
2896            None,
2897        )
2898        .unwrap();
2899
2900        assert!(repo.contains("🗀 Files"));
2901        assert!(repo.contains("🖹 Commits"));
2902        assert!(repo.contains("⑃ Branches"));
2903        assert!(repo.contains("⌆ Tags"));
2904    }
2905
2906    #[test]
2907    fn repo_page_renders_and_persists_thanks_count() {
2908        let tmp = tempfile::tempdir().unwrap();
2909        let config = cfg(tmp.path());
2910        let repo_path = create_repo(
2911            config.repositories_dir.join("public/alpha"),
2912            "README.md",
2913            "# Alpha\n",
2914        );
2915        let access = access(&config);
2916
2917        let first = render_page(
2918            PATH_REPO,
2919            &config,
2920            &access,
2921            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
2922            None,
2923        )
2924        .unwrap();
2925        assert!(first.contains("Thanks (0)"));
2926        assert!(!repo_path.with_extension("thanks").exists());
2927
2928        let thanked = render_page(
2929            PATH_REPO,
2930            &config,
2931            &access,
2932            &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_thanks", "y")]),
2933            None,
2934        )
2935        .unwrap();
2936        assert!(thanked.contains("Thanks (1)"));
2937        assert_eq!(
2938            fs::read_to_string(repo_path.with_extension("thanks")).unwrap(),
2939            "1\n"
2940        );
2941
2942        let incremented = render_page(
2943            PATH_REPO,
2944            &config,
2945            &access,
2946            &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_thanks", "y")]),
2947            None,
2948        )
2949        .unwrap();
2950        assert!(incremented.contains("Thanks (2)"));
2951
2952        let viewed = render_page(
2953            PATH_REPO,
2954            &config,
2955            &access,
2956            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
2957            None,
2958        )
2959        .unwrap();
2960        assert!(viewed.contains("Thanks (2)"));
2961    }
2962
2963    #[test]
2964    fn repo_page_uses_unicode_heart_for_thanks_when_enabled() {
2965        let tmp = tempfile::tempdir().unwrap();
2966        let mut config = cfg(tmp.path());
2967        config.unicode_icons = true;
2968        create_repo(
2969            config.repositories_dir.join("public/alpha"),
2970            "README.md",
2971            "# Alpha\n",
2972        );
2973        let access = access(&config);
2974
2975        let page = render_page(
2976            PATH_REPO,
2977            &config,
2978            &access,
2979            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
2980            None,
2981        )
2982        .unwrap();
2983
2984        assert!(page.contains("♥ Thanks (0)"));
2985        assert!(page.contains("thanks=y"));
2986    }
2987
2988    #[test]
2989    fn empty_page_request_data_is_deterministic() {
2990        let tmp = tempfile::tempdir().unwrap();
2991        let config = cfg(tmp.path());
2992        let access = access(&config);
2993
2994        let index = render_page(PATH_INDEX, &config, &access, &[], None).unwrap();
2995        assert!(index.contains(">Groups"));
2996
2997        let err = render_page(PATH_REPO, &config, &access, &[], None).unwrap_err();
2998        assert!(err.to_string().contains("missing page variable g"));
2999    }
3000
3001    #[test]
3002    fn tree_blob_and_commit_pages_escape_content_and_format_metadata() {
3003        let tmp = tempfile::tempdir().unwrap();
3004        let config = cfg(tmp.path());
3005        let repo = create_repo(
3006            config.repositories_dir.join("public/alpha"),
3007            "src/lib.rs",
3008            "pub fn `answer`() -> u8 { 42 }\n",
3009        );
3010        let access = access(&config);
3011
3012        let tree = render_page(
3013            PATH_TREE,
3014            &config,
3015            &access,
3016            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3017            None,
3018        )
3019        .unwrap();
3020        assert!(tree.contains("`[src/`:/page/tree.mu`g=public|r=alpha|ref=HEAD|path=src]"));
3021
3022        let blob = render_page(
3023            PATH_BLOB,
3024            &config,
3025            &access,
3026            &page_request(&[
3027                ("var_g", "public"),
3028                ("var_r", "alpha"),
3029                ("var_path", "src/lib.rs"),
3030            ]),
3031            None,
3032        )
3033        .unwrap();
3034        assert!(blob.contains("Text, "));
3035        assert!(!blob.contains("View raw"));
3036        assert!(blob.contains("answer"));
3037        assert!(blob.contains("\\`"));
3038
3039        let commit_hash = run_git(
3040            Command::new("git")
3041                .arg("--git-dir")
3042                .arg(&repo)
3043                .args(["rev-parse", "refs/heads/main"]),
3044        );
3045        let commit = render_page(
3046            PATH_COMMIT,
3047            &config,
3048            &access,
3049            &page_request(&[
3050                ("var_g", "public"),
3051                ("var_r", "alpha"),
3052                ("var_h", commit_hash.trim()),
3053            ]),
3054            None,
3055        )
3056        .unwrap();
3057        assert!(commit.contains("Author: RNS Page Test"));
3058        assert!(commit.contains("Files changed"));
3059        assert!(commit.contains("A src/lib.rs"));
3060    }
3061
3062    #[test]
3063    fn markdown_converter_handles_links_code_lists_rules_and_tables() {
3064        let input = "# Title\n\
3065\n\
3066See [the docs](https://example.invalid/a_b) and `literal *code*`.\n\
3067\n\
3068- **bold** and *italic*\n\
3069---\n\
3070| Name | Link |\n\
3071| --- | --- |\n\
3072| Row | [go](rns://abc) |\n";
3073
3074        let out = markdown_to_micron(input);
3075        assert!(out.contains(">Title"));
3076        assert!(out.contains("`!`[the docs`https://example.invalid/a_b]`!"));
3077        assert!(out.contains("`BT383838`Fdddliteral *code*`f`b"));
3078        assert!(out.contains(" • `!bold`! and `*italic`*"));
3079        assert!(out.contains("\n-\n"));
3080        assert!(out.contains("│ Name"));
3081        assert!(out.contains("`!`[go`rns://abc]`!"));
3082    }
3083
3084    #[test]
3085    fn markdown_converter_handles_blockquotes_and_heading_inline_markup() {
3086        let out = markdown_to_micron(
3087            "# **Important**\n\
3088> **Quoted** text\n\
3089> with [link](rns://abc)\n\
3090\n\
3091after\n",
3092        );
3093        assert!(out.contains(">`!Important`!"));
3094        assert!(out.contains(" │ `!Quoted`! text with `!`[link`rns://abc]`!"));
3095        assert!(out.contains("\nafter\n"));
3096    }
3097
3098    #[test]
3099    fn markdown_link_conversion_is_isolated_from_later_substitutions() {
3100        let out = markdown_to_micron("[**bold** `code`](https://example.invalid?q=*x*)");
3101        assert!(out.contains("`!`[**bold** code`https://example.invalid?q=*x*]`!"));
3102        assert!(!out.contains("`!`!"));
3103        assert!(!out.contains("`*x`*"));
3104    }
3105
3106    #[test]
3107    fn markdown_inline_code_that_looks_like_link_does_not_hide_real_links() {
3108        let out = markdown_to_micron(
3109            "`[literal](https://example.invalid/no)` and [real](https://example.invalid/yes)",
3110        );
3111
3112        assert!(out.contains("`BT383838`Fddd[literal](https://example.invalid/no)`f`b"));
3113        assert!(out.contains("`!`[real`https://example.invalid/yes]`!"));
3114        assert!(!out.contains("`!`[literal`https://example.invalid/no]`!"));
3115    }
3116
3117    #[test]
3118    fn markdown_tables_handle_empty_cells_escaped_pipes_alignment_and_visible_width() {
3119        let input = "| Name | Status | Notes |\n\
3120| :--- | ---: | :---: |\n\
3121| `code` | **ok** | a\\|b |\n\
3122| empty | | [go](rns://abc) |\n";
3123
3124        let out = markdown_to_micron(input);
3125        assert!(out.contains("┌"));
3126        assert!(out.contains("├"));
3127        assert!(out.contains("└"));
3128        assert!(out.contains("│ Name"));
3129        assert!(out.contains("`BT383838`Fdddcode`f`b"));
3130        assert!(out.contains("`!ok`!"));
3131        assert!(out.contains("a|b"));
3132        assert!(out.contains("empty"));
3133        assert!(out.contains("`!`[go`rns://abc]`!"));
3134        assert!(!out.contains("a\\|b"));
3135    }
3136
3137    #[test]
3138    fn markdown_table_width_ignores_generated_micron_tags() {
3139        let input = "| A | B |\n\
3140| --- | --- |\n\
3141| [x](rns://abc) | `y` |\n";
3142
3143        let out = markdown_to_micron(input);
3144        assert!(out.contains("│ `!`[x`rns://abc]`! "));
3145        assert!(out.contains("│ `BT383838`Fdddy`f`b "));
3146        assert!(out.contains("┌─────┬─────┐"));
3147    }
3148
3149    #[test]
3150    fn markdown_escaping_does_not_create_or_corrupt_micron_tags() {
3151        let out =
3152            markdown_to_micron("literal `!not bold`! and [bad`label](rns://abc`def) plus `raw`");
3153        assert!(out.contains("literal \\`!not bold\\`!"));
3154        assert!(out.contains("`!`[badlabel`rns://abcdef]`!"));
3155        assert!(out.contains("`BT383838`Fdddraw`f`b"));
3156        assert!(!out.contains("`!not bold`!"));
3157    }
3158
3159    #[test]
3160    fn markdown_escapes_backslashes_and_expands_tabs_before_inline_formatting() {
3161        let out = markdown_to_micron("plain\\path\t**bold\\path** and `code\\path`\n");
3162
3163        assert!(out.contains("plain\\\\path   `!bold\\\\path`!"));
3164        assert!(out.contains("`BT383838`Fdddcode\\\\path`f`b"));
3165    }
3166
3167    #[test]
3168    fn markdown_plain_lines_escape_micron_line_start_controls() {
3169        let out = markdown_to_micron("-not a list\n<not alignment\n- list item\n---\n");
3170        assert!(out.contains("\\-not a list\n"));
3171        assert!(out.contains("\\<not alignment\n"));
3172        assert!(out.contains(" • list item\n"));
3173        assert!(out.contains("-\n"));
3174    }
3175
3176    #[test]
3177    fn markdown_converter_keeps_code_blocks_and_unmatched_markers_literal() {
3178        let out = markdown_to_micron(
3179            "```rust\n\
3180**not bold** and `not inline`\n\
3181```\n\
3182Unmatched * marker\n\
31831. numbered\n",
3184        );
3185        assert!(out.contains("not bold"));
3186        assert!(out.contains("\\`not inline\\`"));
3187        #[cfg(feature = "syntax-highlighting")]
3188        assert!(out.contains("`FT"));
3189        #[cfg(not(feature = "syntax-highlighting"))]
3190        assert!(out.contains("`=\n**not bold** and \\`not inline\\`\n`=\n"));
3191        assert!(!out.contains("`!not bold`!"));
3192        assert!(out.contains("Unmatched * marker"));
3193        assert!(out.contains("1. numbered"));
3194    }
3195
3196    #[cfg(feature = "syntax-highlighting")]
3197    #[test]
3198    fn rust_blob_page_uses_syntax_highlighting_and_escapes_source() {
3199        let tmp = tempfile::tempdir().unwrap();
3200        let config = cfg(tmp.path());
3201        create_repo(
3202            config.repositories_dir.join("public/highlighted"),
3203            "src/lib.rs",
3204            "pub fn `answer`() -> u8 { 42 }\n",
3205        );
3206        let access = access(&config);
3207
3208        let blob = render_page(
3209            PATH_BLOB,
3210            &config,
3211            &access,
3212            &page_request(&[
3213                ("var_g", "public"),
3214                ("var_r", "highlighted"),
3215                ("var_path", "src/lib.rs"),
3216            ]),
3217            None,
3218        )
3219        .unwrap();
3220
3221        assert!(blob.contains("`FT"));
3222        assert!(blob.contains("\\`"));
3223        assert!(blob.contains("answer"));
3224    }
3225
3226    #[cfg(feature = "syntax-highlighting")]
3227    #[test]
3228    fn markdown_readme_rust_fence_uses_syntax_highlighting() {
3229        let tmp = tempfile::tempdir().unwrap();
3230        let config = cfg(tmp.path());
3231        create_repo(
3232            config.repositories_dir.join("public/readme-code"),
3233            "README.md",
3234            "# Example\n\n```rust\npub fn `answer`() -> u8 { 42 }\n```\n",
3235        );
3236        let access = access(&config);
3237
3238        let page = render_page(
3239            PATH_REPO,
3240            &config,
3241            &access,
3242            &page_request(&[("var_g", "public"), ("var_r", "readme-code")]),
3243            None,
3244        )
3245        .unwrap();
3246
3247        assert!(page.contains("`FT"));
3248        assert!(page.contains("\\`"));
3249        assert!(page.contains("answer"));
3250    }
3251
3252    #[test]
3253    fn unknown_extension_and_fence_language_fall_back_to_plain_literals() {
3254        let blob = crate::highlight::literal_block("value `tick`\n", Some("blob.unknown"), None);
3255        assert!(!blob.contains("`FT"));
3256        assert!(blob.contains("value \\`tick\\`"));
3257
3258        let markdown = markdown_to_micron("```not-a-language\nvalue `tick`\n```\n");
3259        assert!(!markdown.contains("`FT"));
3260        assert!(markdown.contains("value \\`tick\\`"));
3261    }
3262
3263    #[test]
3264    fn binary_and_oversized_blobs_use_plain_fallback_messages() {
3265        let tmp = tempfile::tempdir().unwrap();
3266        let config = cfg(tmp.path());
3267        create_repo_bytes(
3268            config.repositories_dir.join("public/binary"),
3269            "payload.bin",
3270            b"abc\0def",
3271        );
3272        let large = vec![b'x'; 256 * 1024 + 1];
3273        create_repo_bytes(
3274            config.repositories_dir.join("public/large"),
3275            "payload.rs",
3276            &large,
3277        );
3278        let access = access(&config);
3279
3280        let binary = render_page(
3281            PATH_BLOB,
3282            &config,
3283            &access,
3284            &page_request(&[
3285                ("var_g", "public"),
3286                ("var_r", "binary"),
3287                ("var_path", "payload.bin"),
3288            ]),
3289            None,
3290        )
3291        .unwrap();
3292        assert!(binary.contains("Binary"));
3293        assert!(binary.contains("Binary file is not displayed."));
3294        assert!(!binary.contains("`FT"));
3295
3296        let oversized = render_page(
3297            PATH_BLOB,
3298            &config,
3299            &access,
3300            &page_request(&[
3301                ("var_g", "public"),
3302                ("var_r", "large"),
3303                ("var_path", "payload.rs"),
3304            ]),
3305            None,
3306        )
3307        .unwrap();
3308        assert!(oversized.contains("File is too large to display"));
3309        assert!(!oversized.contains("`FT"));
3310    }
3311
3312    #[test]
3313    fn markdown_blob_defaults_to_rendered_view_and_raw_view_escapes_source() {
3314        let tmp = tempfile::tempdir().unwrap();
3315        let config = cfg(tmp.path());
3316        create_repo(
3317            config.repositories_dir.join("public/docs"),
3318            "docs/readme.md",
3319            "# Title\n\nSee [docs](https://example.invalid), [guide](guide.md#intro) and `tick`.\n",
3320        );
3321        let access = access(&config);
3322
3323        let rendered = render_page(
3324            PATH_BLOB,
3325            &config,
3326            &access,
3327            &page_request(&[
3328                ("var_g", "public"),
3329                ("var_r", "docs"),
3330                ("var_path", "docs/readme.md"),
3331            ]),
3332            None,
3333        )
3334        .unwrap();
3335        assert!(rendered.contains("Displaying Rendered"));
3336        assert!(rendered.contains("View raw"));
3337        assert!(rendered.contains(">Title"));
3338        assert!(rendered.contains("`!`[docs`https://example.invalid]`!"));
3339        assert!(rendered.contains(
3340            "`!`[guide`:/page/blob.mu`g=public|r=docs|ref=HEAD|path=docs/guide.md|anchor=intro]`!"
3341        ));
3342        assert!(rendered.contains("`BT383838`Fdddtick`f`b"));
3343        assert!(!rendered.contains("# Title"));
3344
3345        let raw = render_page(
3346            PATH_BLOB,
3347            &config,
3348            &access,
3349            &page_request(&[
3350                ("var_g", "public"),
3351                ("var_r", "docs"),
3352                ("var_path", "docs/readme.md"),
3353                ("var_raw", "y"),
3354            ]),
3355            None,
3356        )
3357        .unwrap();
3358        assert!(raw.contains("Displaying Raw"));
3359        assert!(raw.contains("View rendered"));
3360        assert!(raw.contains("Title"));
3361        assert!(raw.contains("\\`"));
3362        assert!(raw.contains("tick"));
3363        assert!(!raw.contains("`!`[docs`https://example.invalid]`!"));
3364    }
3365
3366    #[test]
3367    fn micron_blob_defaults_to_rendered_passthrough_and_raw_view_is_literal() {
3368        let tmp = tempfile::tempdir().unwrap();
3369        let config = cfg(tmp.path());
3370        create_repo(
3371            config.repositories_dir.join("public/micron"),
3372            "README.mu",
3373            ">Micron\n\n`!Already formatted`!\n",
3374        );
3375        let access = access(&config);
3376
3377        let rendered = render_page(
3378            PATH_BLOB,
3379            &config,
3380            &access,
3381            &page_request(&[
3382                ("var_g", "public"),
3383                ("var_r", "micron"),
3384                ("var_path", "README.mu"),
3385            ]),
3386            None,
3387        )
3388        .unwrap();
3389        assert!(rendered.contains("Displaying Rendered"));
3390        assert!(rendered.contains(">Micron"));
3391        assert!(rendered.contains("`!Already formatted`!"));
3392        assert!(!rendered.contains("\\`!Already formatted\\`!"));
3393
3394        let raw = render_page(
3395            PATH_BLOB,
3396            &config,
3397            &access,
3398            &page_request(&[
3399                ("var_g", "public"),
3400                ("var_r", "micron"),
3401                ("var_path", "README.mu"),
3402                ("var_raw", "y"),
3403            ]),
3404            None,
3405        )
3406        .unwrap();
3407        assert!(raw.contains("Displaying Raw"));
3408        assert!(raw.contains("\\`!Already formatted\\`!"));
3409    }
3410
3411    #[test]
3412    fn explicit_render_parameter_renders_markdown_blob() {
3413        let tmp = tempfile::tempdir().unwrap();
3414        let config = cfg(tmp.path());
3415        create_repo(
3416            config.repositories_dir.join("public/docs"),
3417            "docs/readme.md",
3418            "# Explicit\n",
3419        );
3420        let access = access(&config);
3421
3422        let rendered = render_page(
3423            PATH_BLOB,
3424            &config,
3425            &access,
3426            &page_request(&[
3427                ("var_g", "public"),
3428                ("var_r", "docs"),
3429                ("var_path", "docs/readme.md"),
3430                ("var_raw", "y"),
3431                ("var_render", "y"),
3432            ]),
3433            None,
3434        )
3435        .unwrap();
3436        assert!(rendered.contains("Displaying Raw"));
3437        assert!(rendered.contains("#"));
3438        assert!(rendered.contains("Explicit"));
3439
3440        let rendered = render_page(
3441            PATH_BLOB,
3442            &config,
3443            &access,
3444            &page_request(&[
3445                ("var_g", "public"),
3446                ("var_r", "docs"),
3447                ("var_path", "docs/readme.md"),
3448                ("var_render", "y"),
3449            ]),
3450            None,
3451        )
3452        .unwrap();
3453        assert!(rendered.contains("Displaying Rendered"));
3454        assert!(rendered.contains(">Explicit"));
3455        assert!(!rendered.contains("# Explicit"));
3456    }
3457
3458    #[test]
3459    fn unsupported_blob_extension_ignores_render_parameter_and_uses_raw_literal() {
3460        let tmp = tempfile::tempdir().unwrap();
3461        let config = cfg(tmp.path());
3462        create_repo(
3463            config.repositories_dir.join("public/text"),
3464            "notes.txt",
3465            "# Not a heading\npath\\name\t`literal`\n",
3466        );
3467        let access = access(&config);
3468
3469        let page = render_page(
3470            PATH_BLOB,
3471            &config,
3472            &access,
3473            &page_request(&[
3474                ("var_g", "public"),
3475                ("var_r", "text"),
3476                ("var_path", "notes.txt"),
3477                ("var_render", "y"),
3478            ]),
3479            None,
3480        )
3481        .unwrap();
3482        assert!(!page.contains("Displaying Rendered"));
3483        assert!(!page.contains("View raw"));
3484        assert!(page.contains("Displaying Raw"));
3485        assert!(page.contains("Download"));
3486        assert!(page.contains("Not a heading"));
3487        assert!(page.contains("path\\\\name   \\`literal\\`"));
3488        assert!(page.contains("\\`literal\\`"));
3489    }
3490
3491    #[test]
3492    fn renderable_binary_and_oversized_blobs_do_not_render_content() {
3493        let tmp = tempfile::tempdir().unwrap();
3494        let config = cfg(tmp.path());
3495        create_repo_bytes(
3496            config.repositories_dir.join("public/binary-md"),
3497            "payload.md",
3498            b"# Binary\0payload\n",
3499        );
3500        let large = vec![b'#'; 256 * 1024 + 1];
3501        create_repo_bytes(
3502            config.repositories_dir.join("public/large-md"),
3503            "payload.md",
3504            &large,
3505        );
3506        let access = access(&config);
3507
3508        let binary = render_page(
3509            PATH_BLOB,
3510            &config,
3511            &access,
3512            &page_request(&[
3513                ("var_g", "public"),
3514                ("var_r", "binary-md"),
3515                ("var_path", "payload.md"),
3516            ]),
3517            None,
3518        )
3519        .unwrap();
3520        assert!(binary.contains("Binary"));
3521        assert!(binary.contains("Binary file is not displayed."));
3522        assert!(!binary.contains(">Binary"));
3523
3524        let oversized = render_page(
3525            PATH_BLOB,
3526            &config,
3527            &access,
3528            &page_request(&[
3529                ("var_g", "public"),
3530                ("var_r", "large-md"),
3531                ("var_path", "payload.md"),
3532                ("var_render", "y"),
3533            ]),
3534            None,
3535        )
3536        .unwrap();
3537        assert!(oversized.contains("File is too large to display"));
3538        assert!(!oversized.contains("Displaying Rendered\n\n>"));
3539    }
3540
3541    #[cfg(not(feature = "syntax-highlighting"))]
3542    #[test]
3543    fn feature_off_renders_plain_literal_blocks() {
3544        let blob = crate::highlight::literal_block("pub fn main() {}\n", Some("main.rs"), None);
3545        assert!(!blob.contains("`FT"));
3546        assert!(blob.contains("pub fn main() {}"));
3547
3548        let markdown = markdown_to_micron("```rust\npub fn main() {}\n```\n");
3549        assert!(!markdown.contains("`FT"));
3550        assert!(markdown.contains("pub fn main() {}"));
3551    }
3552
3553    #[test]
3554    fn repo_page_renders_markdown_readme_and_passes_micron_readme_through() {
3555        let tmp = tempfile::tempdir().unwrap();
3556        let config = cfg(tmp.path());
3557        create_repo(
3558            config.repositories_dir.join("public/markdown"),
3559            "README.md",
3560            "# Markdown\n\nSee [docs](https://example.invalid) and [local](docs/intro.md).\n",
3561        );
3562        create_repo(
3563            config.repositories_dir.join("public/micron"),
3564            "README.mu",
3565            ">Micron\n\n`!Already formatted`!\n",
3566        );
3567        let access = access(&config);
3568
3569        let markdown = render_page(
3570            PATH_REPO,
3571            &config,
3572            &access,
3573            &page_request(&[("var_g", "public"), ("var_r", "markdown")]),
3574            None,
3575        )
3576        .unwrap();
3577        assert!(markdown.contains(">Markdown"));
3578        assert!(markdown.contains("`!`[docs`https://example.invalid]`!"));
3579        assert!(markdown.contains(
3580            "`!`[local`:/page/blob.mu`g=public|r=markdown|ref=HEAD|path=docs/intro.md]`!"
3581        ));
3582
3583        let micron = render_page(
3584            PATH_REPO,
3585            &config,
3586            &access,
3587            &page_request(&[("var_g", "public"), ("var_r", "micron")]),
3588            None,
3589        )
3590        .unwrap();
3591        assert!(micron.contains(">Micron"));
3592        assert!(micron.contains("`!Already formatted`!"));
3593    }
3594
3595    #[test]
3596    fn work_pages_render_documents_comments_and_repo_links() {
3597        let tmp = tempfile::tempdir().unwrap();
3598        let config = cfg(tmp.path());
3599        let repo_path = create_repo(
3600            config.repositories_dir.join("public/worked"),
3601            "README.md",
3602            "# Worked\n",
3603        );
3604        let work_path = crate::work::work_sidecar_path(&repo_path);
3605        let created = crate::work::create_document(
3606            &work_path,
3607            crate::work::WorkInput {
3608                title: "Active task".into(),
3609                content: "# Active Body\n".into(),
3610                format: "markdown".into(),
3611                signature: None,
3612                author: [0x11; 16],
3613            },
3614        )
3615        .unwrap();
3616        crate::work::add_comment(
3617            &work_path,
3618            crate::work::WorkScope::Active,
3619            created.id,
3620            crate::work::WorkCommentInput {
3621                content: "Progress update".into(),
3622                format: "markdown".into(),
3623                signature: None,
3624                author: [0x22; 16],
3625            },
3626        )
3627        .unwrap();
3628        let completed = crate::work::create_document(
3629            &work_path,
3630            crate::work::WorkInput {
3631                title: "Completed task".into(),
3632                content: "Done".into(),
3633                format: "micron".into(),
3634                signature: None,
3635                author: [0x11; 16],
3636            },
3637        )
3638        .unwrap();
3639        crate::work::complete_document(&work_path, completed.id, &[0x11; 16]).unwrap();
3640        let access = access(&config);
3641
3642        let repo = render_page(
3643            PATH_REPO,
3644            &config,
3645            &access,
3646            &page_request(&[("var_g", "public"), ("var_r", "worked")]),
3647            None,
3648        )
3649        .unwrap();
3650        assert!(repo.contains("Work (2)"));
3651        assert!(repo.contains(PATH_WORK));
3652
3653        let active = render_page(
3654            PATH_WORK,
3655            &config,
3656            &access,
3657            &page_request(&[("var_g", "public"), ("var_r", "worked")]),
3658            None,
3659        )
3660        .unwrap();
3661        assert!(active.contains(">Active Work Documents (1)"));
3662        assert!(active.contains("#1 Active task"));
3663        assert!(active.contains("1 updates"));
3664        assert!(!active.contains("Completed task"));
3665
3666        let completed_page = render_page(
3667            PATH_WORK,
3668            &config,
3669            &access,
3670            &page_request(&[
3671                ("var_g", "public"),
3672                ("var_r", "worked"),
3673                ("var_scope", "completed"),
3674            ]),
3675            None,
3676        )
3677        .unwrap();
3678        assert!(completed_page.contains(">Completed Work Documents (1)"));
3679        assert!(completed_page.contains("Completed task"));
3680
3681        let doc = render_page(
3682            PATH_WORK_DOC,
3683            &config,
3684            &access,
3685            &page_request(&[("var_g", "public"), ("var_r", "worked"), ("var_id", "1")]),
3686            None,
3687        )
3688        .unwrap();
3689        assert!(doc.contains(">Active task"));
3690        assert!(doc.contains(">Active Body"));
3691        assert!(doc.contains(">Updates (1)"));
3692        assert!(doc.contains("Progress update"));
3693    }
3694
3695    #[test]
3696    fn work_pages_respect_read_access() {
3697        let tmp = tempfile::tempdir().unwrap();
3698        let mut config = cfg(tmp.path());
3699        create_repo(
3700            config.repositories_dir.join("public/private-work"),
3701            "README.md",
3702            "# Private\n",
3703        );
3704        config.allow_read = vec!["none".into()];
3705        let access = access(&config);
3706
3707        assert!(render_page(
3708            PATH_WORK,
3709            &config,
3710            &access,
3711            &page_request(&[("var_g", "public"), ("var_r", "private-work")]),
3712            None,
3713        )
3714        .is_err());
3715    }
3716
3717    fn cfg(root: &std::path::Path) -> ServerConfig {
3718        ServerConfig {
3719            dir: root.to_path_buf(),
3720            reticulum_dir: None,
3721            repositories_dir: root.join("repositories"),
3722            identity_path: root.join("repositories_identity"),
3723            client_identity_path: root.join("client_identity"),
3724            node_name: "Test Git Node".into(),
3725            announce_interval_secs: 300,
3726            serve_nomadnet: true,
3727            templates_dir: root.join("templates"),
3728            unicode_icons: false,
3729            record_stats: false,
3730            stats_ignore_identities: Vec::new(),
3731            allow_read: vec!["all".into()],
3732            allow_write: vec!["none".into()],
3733            allow_create: vec!["none".into()],
3734            allow_stats: vec!["none".into()],
3735            allow_release: vec!["none".into()],
3736            allow_interact: vec!["none".into()],
3737            allow_admin: vec!["none".into()],
3738            log_level: logging::DEFAULT_LOG_LEVEL,
3739        }
3740    }
3741
3742    fn access(config: &ServerConfig) -> Access {
3743        Access::new(
3744            &config.allow_read,
3745            &config.allow_write,
3746            &config.allow_create,
3747            &config.allow_stats,
3748            &config.allow_release,
3749            &config.allow_interact,
3750            &config.allow_admin,
3751            config.repositories_dir.clone(),
3752        )
3753        .unwrap()
3754    }
3755
3756    fn page_request(fields: &[(&str, &str)]) -> Vec<u8> {
3757        msgpack::pack(&Value::Map(
3758            fields
3759                .iter()
3760                .map(|(k, v)| (Value::Str((*k).into()), Value::Str((*v).into())))
3761                .collect(),
3762        ))
3763    }
3764
3765    fn create_repo(path: std::path::PathBuf, file: &str, content: &str) -> std::path::PathBuf {
3766        create_repo_bytes(path, file, content.as_bytes())
3767    }
3768
3769    fn create_repo_bytes(
3770        path: std::path::PathBuf,
3771        file: &str,
3772        content: &[u8],
3773    ) -> std::path::PathBuf {
3774        let work = path.with_extension("work");
3775        fs::create_dir_all(&work).unwrap();
3776        run_git(Command::new("git").arg("init").arg(&work));
3777        let file_path = work.join(file);
3778        fs::create_dir_all(file_path.parent().unwrap()).unwrap();
3779        fs::write(&file_path, content).unwrap();
3780        run_git(Command::new("git").arg("-C").arg(&work).arg("add").arg("."));
3781        run_git(
3782            Command::new("git")
3783                .arg("-C")
3784                .arg(&work)
3785                .arg("-c")
3786                .arg("user.name=RNS Page Test")
3787                .arg("-c")
3788                .arg("user.email=rns-page-test@example.invalid")
3789                .arg("commit")
3790                .arg("-m")
3791                .arg("initial"),
3792        );
3793        run_git(
3794            Command::new("git")
3795                .arg("-C")
3796                .arg(&work)
3797                .args(["branch", "-M", "main"]),
3798        );
3799        run_git(
3800            Command::new("git")
3801                .arg("clone")
3802                .arg("--bare")
3803                .arg(&work)
3804                .arg(&path),
3805        );
3806        path
3807    }
3808
3809    fn run_git(cmd: &mut Command) -> String {
3810        let output = cmd.output().unwrap();
3811        assert!(
3812            output.status.success(),
3813            "git command failed: {}",
3814            String::from_utf8_lossy(&output.stderr)
3815        );
3816        String::from_utf8_lossy(&output.stdout).into_owned()
3817    }
3818}