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, SystemTime, UNIX_EPOCH};
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";
37pub const PATH_WORK_DOC_DOWNLOAD: &str = "/file/workdoc";
38
39const GIT_COMMAND_TIMEOUT: Duration = Duration::from_secs(8);
40const RCLR_PUSH: &str = "B9A810";
41const RCLR_PUSH_G: &str = "791212";
42const RCLR_FETCH: &str = "10b981";
43const RCLR_FETCH_G: &str = "1c5e71";
44const RCLR_VIEW: &str = "3b82f6";
45const RCLR_VIEW_G: &str = "13428A";
46const RCLR_DOWNLOAD: &str = "7831E0";
47const RCLR_DOWNLOAD_G: &str = "c5754d";
48const NULL_IDENTITY_HASH: [u8; 16] = [
49    0xd7, 0xdb, 0x22, 0xf6, 0x3b, 0x45, 0x3c, 0x23, 0xbb, 0x06, 0x88, 0xdd, 0xe5, 0x65, 0xb7, 0xc1,
50];
51
52const PAGE_PATHS: &[&str] = &[
53    PATH_INDEX,
54    PATH_GROUP,
55    PATH_REPO,
56    PATH_TREE,
57    PATH_BLOB,
58    PATH_COMMITS,
59    PATH_COMMIT,
60    PATH_REFS,
61    PATH_STATS,
62    PATH_RELEASES,
63    PATH_RELEASE,
64    PATH_WORK,
65    PATH_WORK_DOC,
66];
67
68pub fn destination_for_identity(identity: &Identity) -> Destination {
69    Destination::single_in(APP_NAME, &[ASPECT_NODE], IdentityHash(*identity.hash()))
70}
71
72pub fn register_nomadnet_destination(
73    node: &RnsNode,
74    config: &ServerConfig,
75    identity: &Identity,
76) -> Result<Destination> {
77    let destination = destination_for_identity(identity);
78    let public_key = identity
79        .get_public_key()
80        .ok_or_else(|| Error::msg("repository identity has no public key"))?;
81    let private_key = identity
82        .get_private_key()
83        .ok_or_else(|| Error::msg("repository identity has no private key"))?;
84    let sig_prv: [u8; 32] = private_key[32..64].try_into().unwrap();
85    let sig_pub: [u8; 32] = public_key[32..64].try_into().unwrap();
86
87    node.register_link_destination(
88        destination.hash.0,
89        sig_prv,
90        sig_pub,
91        ResourceStrategy::AcceptAll as u8,
92    )
93    .map_err(|_| Error::msg("failed to register Nomad Network page destination"))?;
94
95    let access = Access::new_with_aliases(
96        &config.allow_read,
97        &config.allow_write,
98        &config.allow_create,
99        &config.allow_stats,
100        &config.allow_release,
101        &config.allow_interact,
102        &config.allow_admin,
103        config.repositories_dir.clone(),
104        config.identity_aliases.clone(),
105    )?
106    .with_propose(&config.allow_propose)?
107    .with_blocked_identities(config.blocked_identities.clone());
108    register_page_handlers(node, config.clone(), access)?;
109
110    Ok(destination)
111}
112
113fn register_page_handlers(node: &RnsNode, config: ServerConfig, access: Access) -> Result<()> {
114    for path in PAGE_PATHS {
115        let handler_path = *path;
116        let handler_config = config.clone();
117        let handler_access = access.clone();
118        node.register_request_handler(handler_path, None, move |_link, path, data, remote| {
119            let remote_hash = remote.map(|(hash, _)| hash);
120            Some(
121                match render_page(path, &handler_config, &handler_access, data, remote_hash) {
122                    Ok(page) => page.into_bytes(),
123                    Err(err) => error_page(&handler_config.node_name, &err.to_string()),
124                },
125            )
126        })
127        .map_err(|_| Error::msg(format!("failed to register page handler {handler_path}")))?;
128    }
129    let download_config = config.clone();
130    let download_access = access.clone();
131    node.register_request_handler_response(
132        PATH_DOWNLOAD,
133        None,
134        move |_link, _path, data, remote| {
135            let remote_hash = remote.map(|(hash, _)| hash);
136            Some(
137                download_file(&download_config, &download_access, data, remote_hash)
138                    .unwrap_or_else(|err| RequestResponse::Bytes(error_response_bytes(&err))),
139            )
140        },
141    )
142    .map_err(|_| Error::msg("failed to register file download handler"))?;
143    let workdoc_download_config = config.clone();
144    let workdoc_download_access = access.clone();
145    node.register_request_handler_response(
146        PATH_WORK_DOC_DOWNLOAD,
147        None,
148        move |_link, _path, data, remote| {
149            let remote_hash = remote.map(|(hash, _)| hash);
150            Some(
151                download_work_document(
152                    &workdoc_download_config,
153                    &workdoc_download_access,
154                    data,
155                    remote_hash,
156                )
157                .unwrap_or_else(|err| RequestResponse::Bytes(error_response_bytes(&err))),
158            )
159        },
160    )
161    .map_err(|_| Error::msg("failed to register work document download handler"))?;
162    Ok(())
163}
164
165pub fn render_page(
166    path: &str,
167    config: &ServerConfig,
168    access: &Access,
169    data: &[u8],
170    remote: Option<&[u8; 16]>,
171) -> Result<String> {
172    let vars = parse_page_vars(data)?;
173    if PAGE_PATHS.contains(&path)
174        && remote.is_none()
175        && config
176            .blocked_identities
177            .iter()
178            .any(|identity| identity == &NULL_IDENTITY_HASH)
179    {
180        record_page_view(path, config, &vars, remote);
181        return Ok(render_template(config, "no_ident", ""));
182    }
183    let (template, content) = match path {
184        PATH_INDEX => ("front", render_front_page(config, access, remote)?),
185        PATH_GROUP => ("group", render_group_page(config, access, remote, &vars)?),
186        PATH_REPO => ("repo", render_repo_page(config, access, remote, &vars)?),
187        PATH_TREE => ("tree", render_tree_page(config, access, remote, &vars)?),
188        PATH_BLOB => ("blob", render_blob_page(config, access, remote, &vars)?),
189        PATH_COMMITS => (
190            "commits",
191            render_commits_page(config, access, remote, &vars)?,
192        ),
193        PATH_COMMIT => ("commit", render_commit_page(config, access, remote, &vars)?),
194        PATH_REFS => ("refs", render_refs_page(config, access, remote, &vars)?),
195        PATH_STATS => ("stats", render_stats_page(config, access, remote, &vars)?),
196        PATH_RELEASES => (
197            "releases",
198            render_releases_page(config, access, remote, &vars)?,
199        ),
200        PATH_RELEASE => (
201            "release",
202            render_release_page(config, access, remote, &vars)?,
203        ),
204        PATH_WORK => ("work", render_work_page(config, access, remote, &vars)?),
205        PATH_WORK_DOC => (
206            "work_doc",
207            render_work_doc_page(config, access, remote, &vars)?,
208        ),
209        _ => return Err(Error::msg("unknown page path")),
210    };
211    record_page_view(path, config, &vars, remote);
212    Ok(render_template(config, template, &content))
213}
214
215fn record_page_view(
216    path: &str,
217    config: &ServerConfig,
218    vars: &BTreeMap<String, String>,
219    remote: Option<&[u8; 16]>,
220) {
221    match path {
222        PATH_INDEX => crate::stats::record_front_view(config, remote),
223        PATH_GROUP => {
224            if let Some(group) = var(vars, "g") {
225                crate::stats::record_group_view(config, group, remote);
226            }
227        }
228        PATH_REPO | PATH_TREE | PATH_BLOB | PATH_COMMITS | PATH_COMMIT | PATH_REFS
229        | PATH_RELEASES | PATH_RELEASE | PATH_WORK | PATH_WORK_DOC => {
230            if let (Some(group), Some(repo)) = (var(vars, "g"), var(vars, "r")) {
231                crate::stats::record_repository_view(config, group, repo, remote);
232            }
233        }
234        _ => {}
235    }
236}
237
238fn parse_page_vars(data: &[u8]) -> Result<BTreeMap<String, String>> {
239    if data.is_empty() {
240        return Ok(BTreeMap::new());
241    }
242    let value =
243        msgpack::unpack_exact(data).map_err(|e| Error::msg(format!("invalid msgpack: {e}")))?;
244    let map = value
245        .as_map()
246        .ok_or_else(|| Error::msg("page request must be a msgpack map"))?;
247    let mut out = BTreeMap::new();
248    for (key, value) in map {
249        let Some(key) = key.as_str() else {
250            continue;
251        };
252        let value = match value {
253            Value::Str(value) => value.clone(),
254            Value::UInt(value) => value.to_string(),
255            Value::Int(value) => value.to_string(),
256            Value::Bool(value) => {
257                if *value {
258                    "true".to_string()
259                } else {
260                    "false".to_string()
261                }
262            }
263            _ => continue,
264        };
265        out.insert(key.to_string(), value);
266    }
267    Ok(out)
268}
269
270fn render_template(config: &ServerConfig, template: &str, content: &str) -> String {
271    let page = apply_template(&page_template(config, template), content, config);
272    apply_template(&base_template(config), &page, config)
273}
274
275fn error_page(node_name: &str, message: &str) -> Vec<u8> {
276    default_base_template()
277        .replace("{PAGE_CONTENT}", &format!(">Error\n\n{message}\n"))
278        .replace("{NODE_NAME}", &m_escape(node_name))
279        .replace("{VERSION}", env!("CARGO_PKG_VERSION"))
280        .replace("{NAVIGATION}", "")
281        .replace("{GEN_TIME}", "0ms")
282        .into_bytes()
283}
284
285fn error_response_bytes(err: &Error) -> Vec<u8> {
286    protocol::status_bytes(protocol::RES_INVALID_REQ, err.to_string())
287}
288
289fn base_template(config: &ServerConfig) -> String {
290    load_template(config, "base").unwrap_or_else(|| default_base_template().to_string())
291}
292
293fn page_template(config: &ServerConfig, template: &str) -> String {
294    load_template(config, template).unwrap_or_else(|| default_page_template(template).to_string())
295}
296
297fn default_page_template(template: &str) -> &'static str {
298    match template {
299        "no_ident" => {
300            ">>No Identity\n\nThis page requires identification, and none was received.\n"
301        }
302        _ => "{PAGE_CONTENT}",
303    }
304}
305
306fn load_template(config: &ServerConfig, template: &str) -> Option<String> {
307    if !template
308        .chars()
309        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
310    {
311        return None;
312    }
313    let path = config.templates_dir.join(format!("{template}.mu"));
314    fs::read_to_string(path)
315        .ok()
316        .map(|template| template.trim_end().to_string())
317}
318
319fn apply_template(template: &str, content: &str, config: &ServerConfig) -> String {
320    template
321        .replace("{NODE_NAME}", &m_escape(&config.node_name))
322        .replace("{VERSION}", env!("CARGO_PKG_VERSION"))
323        .replace("{NAVIGATION}", "")
324        .replace("{GEN_TIME}", "0ms")
325        .replace("{PAGE_CONTENT}", content)
326}
327
328fn default_base_template() -> &'static str {
329    "#!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"
330}
331
332fn icon(config: &ServerConfig, name: &str) -> &'static str {
333    if !config.unicode_icons {
334        return "";
335    }
336    match name {
337        "sep" => "•",
338        "folder" => "🗀",
339        "file" => "🗎",
340        "branch" => "⑃",
341        "tag" => "⌆",
342        "commits" => "🖹",
343        "package" => "▣",
344        "stats" => "🗠",
345        "heart" => "♥",
346        _ => "",
347    }
348}
349
350fn icon_label(config: &ServerConfig, name: &str, label: &str) -> String {
351    let icon = icon(config, name);
352    if icon.is_empty() {
353        label.to_string()
354    } else {
355        format!("{icon} {label}")
356    }
357}
358
359fn icon_sep(config: &ServerConfig) -> &'static str {
360    let sep = icon(config, "sep");
361    if sep.is_empty() {
362        "•"
363    } else {
364        sep
365    }
366}
367
368fn m_link(label: &str, path: &str, fields: &[(&str, &str)]) -> String {
369    format!("`!{}`!", m_link_raw(label, path, fields))
370}
371
372fn m_link_raw(label: &str, path: &str, fields: &[(&str, &str)]) -> String {
373    let mut out = String::from("`[");
374    out.push_str(&sanitize_label(label));
375    out.push_str("`:");
376    out.push_str(path);
377    if !fields.is_empty() {
378        out.push('`');
379        for (i, (key, value)) in fields.iter().enumerate() {
380            if i > 0 {
381                out.push('|');
382            }
383            out.push_str(key);
384            out.push('=');
385            out.push_str(&sanitize_field(value));
386        }
387    }
388    out.push(']');
389    out
390}
391
392fn markdown_blob_url_scope(group: &str, repo: &str, reference: &str, current_path: &str) -> String {
393    let directory = current_path
394        .rsplit_once('/')
395        .map(|(directory, _)| format!("{directory}/"))
396        .unwrap_or_default();
397    let mut out = format!(":{PATH_BLOB}`");
398    for (index, (key, value)) in [
399        ("g", group),
400        ("r", repo),
401        ("ref", reference),
402        ("path", directory.as_str()),
403    ]
404    .iter()
405    .enumerate()
406    {
407        if index > 0 {
408            out.push('|');
409        }
410        out.push_str(key);
411        out.push('=');
412        out.push_str(&sanitize_field(value));
413    }
414    out
415}
416
417fn sanitize_label(value: &str) -> String {
418    value
419        .chars()
420        .filter(|ch| *ch != '[' && *ch != ']' && *ch != '`')
421        .collect()
422}
423
424fn sanitize_field(value: &str) -> String {
425    let mut out = String::new();
426    for byte in value.bytes() {
427        match byte {
428            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'/' => {
429                out.push(byte as char)
430            }
431            b' ' => out.push('+'),
432            _ => out.push_str(&format!("%{byte:02X}")),
433        }
434    }
435    out
436}
437
438fn m_escape(value: &str) -> String {
439    value
440        .replace('\\', "\\\\")
441        .replace('`', "\\`")
442        .replace('\t', "   ")
443}
444
445fn m_escape_line_start_controls(value: &str) -> String {
446    let escaped = m_escape(value);
447    if value.starts_with('-') || value.starts_with('>') || value.starts_with('<') {
448        format!("\\{escaped}")
449    } else {
450        escaped
451    }
452}
453
454fn format_commit_message(value: &str) -> String {
455    value
456        .split('\n')
457        .map(|line| {
458            let escaped = m_escape(line);
459            if line.starts_with('-') {
460                format!("\\{escaped}")
461            } else {
462                escaped
463            }
464        })
465        .collect::<Vec<_>>()
466        .join("\n")
467}
468
469fn join_git_path(parent: &str, child: &str) -> String {
470    if parent.is_empty() {
471        child.to_string()
472    } else {
473        format!("{parent}/{child}")
474    }
475}
476
477fn format_size(size: u64) -> String {
478    const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB"];
479    let mut value = size as f64;
480    let mut unit = 0;
481    while value >= 1024.0 && unit + 1 < UNITS.len() {
482        value /= 1024.0;
483        unit += 1;
484    }
485    if unit == 0 {
486        format!("{size} B")
487    } else {
488        format!("{value:.1} {}", UNITS[unit])
489    }
490}
491
492fn format_unix_time(timestamp: i64) -> String {
493    timestamp.to_string()
494}
495
496fn render_front_page(
497    config: &ServerConfig,
498    access: &Access,
499    remote: Option<&[u8; 16]>,
500) -> Result<String> {
501    let groups = accessible_groups(config, access, remote)?;
502    let mut out = format!(">>\n{} /\n\n>Groups\n\n", m_link("Node", PATH_INDEX, &[]));
503    if groups.is_empty() {
504        out.push_str("No repository groups available.\n");
505        return Ok(out);
506    }
507    for (group, repos) in groups {
508        out.push_str(&format!(
509            "{} ({} repositories)\n",
510            m_link(&group, PATH_GROUP, &[("g", &group)]),
511            repos.len()
512        ));
513        for repo in repos {
514            out.push_str(&format!(
515                "  - {}\n",
516                m_link_raw(&repo, PATH_REPO, &[("g", &group), ("r", &repo)])
517            ));
518        }
519    }
520    Ok(out)
521}
522
523fn render_group_page(
524    config: &ServerConfig,
525    access: &Access,
526    remote: Option<&[u8; 16]>,
527    vars: &BTreeMap<String, String>,
528) -> Result<String> {
529    let group = required_var(vars, "g")?;
530    validate_group_name(group)?;
531    let repos = accessible_repositories(config, access, remote, group)?;
532    if repos.is_empty() {
533        return Ok(">Group Not Found\n\nThe requested group was not found.\n".to_string());
534    }
535
536    let mut out = format!(
537        ">>\n{} / {}\n\n>Repositories\n\n",
538        m_link("Node", PATH_INDEX, &[]),
539        m_escape(group)
540    );
541    for repo in repos {
542        let repo_path = config.repositories_dir.join(group).join(&repo);
543        let description = repository_description(&repo_path)?;
544        out.push_str(&format!(
545            "- {}",
546            m_link(&repo, PATH_REPO, &[("g", group), ("r", &repo)])
547        ));
548        if !description.is_empty() {
549            out.push_str(&format!(" - {}", m_escape(&description)));
550        }
551        out.push('\n');
552    }
553    Ok(out)
554}
555
556fn render_repo_page(
557    config: &ServerConfig,
558    access: &Access,
559    remote: Option<&[u8; 16]>,
560    vars: &BTreeMap<String, String>,
561) -> Result<String> {
562    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
563    let description = repository_description(&repository)?;
564    let origin = repository_origin(&repository)?;
565    let refs = git_refs(&repository)?;
566    let readme = readme_content(&repository)?;
567    let repo_url = format!("rns://<repository-destination>/{group}/{repo}");
568    let thanks = repository_thanks(&repository, var(vars, "thanks").is_some())?;
569    let releases_path = crate::release::release_sidecar_path(&repository);
570    let release_count = crate::release::list_releases(&releases_path)?
571        .into_iter()
572        .filter(|release| release.status == "published")
573        .count();
574    let work_path = crate::work::work_sidecar_path(&repository);
575    let work_lists = crate::work::list_documents(&work_path, crate::work::WorkListScope::All)?;
576    let work_count = work_lists.active.len() + work_lists.completed.len();
577
578    let branch_count = refs.heads.len().to_string();
579    let tag_count = refs.tags.len().to_string();
580    let mut out = format!(
581        ">>\n{} / {} / {} `F666{}`f\n",
582        m_link("Node", PATH_INDEX, &[]),
583        m_link(&group, PATH_GROUP, &[("g", &group)]),
584        m_escape(&repo),
585        m_escape(&repo_url)
586    );
587    if let Some(origin) = origin {
588        out.push_str(&format!(
589            "`F666{} from {}`f",
590            origin.label(),
591            m_escape(&origin.source)
592        ));
593        if let Some(synced_at) = origin.synced_at {
594            out.push_str(&format!(
595                " `*`F666synced {} ago`f`*",
596                format_sync_age(synced_at)
597            ));
598        }
599        out.push('\n');
600    }
601    out.push('\n');
602    if !description.is_empty() {
603        out.push_str(&format!("{}\n\n", m_escape(&description)));
604    }
605    let mut nav_links = vec![
606        m_link_raw(
607            &icon_label(config, "folder", "Files"),
608            PATH_TREE,
609            &[("g", &group), ("r", &repo), ("ref", "HEAD")],
610        ),
611        m_link_raw(
612            &icon_label(config, "commits", "Commits"),
613            PATH_COMMITS,
614            &[("g", &group), ("r", &repo), ("ref", "HEAD")],
615        ),
616        m_link_raw(
617            &icon_label(config, "branch", &format!("Branches ({branch_count})")),
618            PATH_REFS,
619            &[("g", &group), ("r", &repo), ("type", "heads")],
620        ),
621        m_link_raw(
622            &icon_label(config, "tag", &format!("Tags ({tag_count})")),
623            PATH_REFS,
624            &[("g", &group), ("r", &repo), ("type", "tags")],
625        ),
626        m_link_raw(
627            &icon_label(config, "heart", &format!("Thanks ({thanks})")),
628            PATH_REPO,
629            &[("g", &group), ("r", &repo), ("thanks", "y")],
630        ),
631    ];
632    if release_count > 0 {
633        nav_links.push(m_link_raw(
634            &icon_label(config, "package", &format!("Releases ({release_count})")),
635            PATH_RELEASES,
636            &[("g", &group), ("r", &repo)],
637        ));
638    }
639    if work_count > 0 {
640        nav_links.push(m_link_raw(
641            &icon_label(config, "work", &format!("Work ({work_count})")),
642            PATH_WORK,
643            &[("g", &group), ("r", &repo)],
644        ));
645    }
646    if access.allows(Operation::Stats, &format!("{group}/{repo}"), remote)? {
647        nav_links.push(m_link_raw(
648            &icon_label(config, "stats", "Stats"),
649            PATH_STATS,
650            &[("g", &group), ("r", &repo)],
651        ));
652    }
653    let sep = format!(" {} ", icon_sep(config));
654    out.push_str(&format!("{}\n\n", nav_links.join(&sep)));
655    if let Some(readme) = readme {
656        if !readme.content.trim_start().starts_with('>') {
657            out.push_str("-\n");
658        }
659        if readme.markdown {
660            out.push_str(&markdown_to_micron_scoped(
661                &readme.content,
662                Some(&markdown_blob_url_scope(&group, &repo, "HEAD", "README.md")),
663            ));
664            if !out.ends_with('\n') {
665                out.push('\n');
666            }
667        } else {
668            out.push_str(readme.content.trim_end());
669            out.push('\n');
670        }
671    } else {
672        out.push_str("No README file found in this repository.\n");
673    }
674    Ok(out)
675}
676
677fn render_tree_page(
678    config: &ServerConfig,
679    access: &Access,
680    remote: Option<&[u8; 16]>,
681    vars: &BTreeMap<String, String>,
682) -> Result<String> {
683    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
684    let reference = var(vars, "ref").unwrap_or("HEAD");
685    let path = var(vars, "path").unwrap_or("");
686    validate_git_path(path)?;
687    let resolved = resolve_ref(&repository, reference)?;
688    let entries = tree_entries(&repository, &resolved, path)?;
689    let title_path = if path.is_empty() { "/" } else { path };
690
691    let mut out = format!(
692        ">>\n{} / {} / {} / files / {}\n\n>Contents: {} ({})\n\n",
693        m_link("Node", PATH_INDEX, &[]),
694        m_link(&group, PATH_GROUP, &[("g", &group)]),
695        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
696        m_escape(title_path),
697        m_escape(reference),
698        &resolved[..8]
699    );
700    if entries.is_empty() {
701        out.push_str("Empty directory.\n");
702    } else {
703        for entry in entries {
704            let entry_path = join_git_path(path, &entry.name);
705            if entry.kind == "tree" {
706                let label = icon_label(config, "folder", &format!("{}/", entry.name));
707                out.push_str(&format!(
708                    "{}\n",
709                    m_link_raw(
710                        &label,
711                        PATH_TREE,
712                        &[
713                            ("g", &group),
714                            ("r", &repo),
715                            ("ref", reference),
716                            ("path", &entry_path)
717                        ]
718                    )
719                ));
720            } else {
721                let label = icon_label(config, "file", &entry.name);
722                out.push_str(&format!(
723                    "{} {}\n",
724                    m_link_raw(
725                        &label,
726                        PATH_BLOB,
727                        &[
728                            ("g", &group),
729                            ("r", &repo),
730                            ("ref", reference),
731                            ("path", &entry_path)
732                        ]
733                    ),
734                    entry.size_label()
735                ));
736            }
737        }
738    }
739    Ok(out)
740}
741
742fn render_blob_page(
743    config: &ServerConfig,
744    access: &Access,
745    remote: Option<&[u8; 16]>,
746    vars: &BTreeMap<String, String>,
747) -> Result<String> {
748    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
749    let reference = var(vars, "ref").unwrap_or("HEAD");
750    let path = normalize_blob_path(required_var(vars, "path")?);
751    validate_git_path(&path)?;
752    let resolved = resolve_ref(&repository, reference)?;
753    if git_object_kind(&repository, &resolved, &path)? == "tree" {
754        return render_tree_page(config, access, remote, vars);
755    }
756    let blob = blob_info(&repository, &resolved, &path)?;
757    let renderable = renderable_blob(&path);
758    let raw_requested = var(vars, "raw").is_some();
759    let render_requested = var(vars, "render").is_some();
760    let render =
761        renderable.is_some() && !raw_requested && (render_requested || render_by_default(&path));
762    let download_link = m_link(
763        "Download",
764        PATH_DOWNLOAD,
765        &[
766            ("g", &group),
767            ("r", &repo),
768            ("ref", reference),
769            ("path", path.as_str()),
770        ],
771    );
772    let controls = if renderable.is_some() {
773        let render_link = m_link(
774            "View rendered",
775            PATH_BLOB,
776            &[
777                ("g", &group),
778                ("r", &repo),
779                ("ref", reference),
780                ("path", path.as_str()),
781                ("render", "y"),
782            ],
783        );
784        let raw_link = m_link(
785            "View raw",
786            PATH_BLOB,
787            &[
788                ("g", &group),
789                ("r", &repo),
790                ("ref", reference),
791                ("path", path.as_str()),
792                ("raw", "y"),
793            ],
794        );
795        let sep = icon_sep(config);
796        Some(if render {
797            format!("Displaying Rendered {sep} {raw_link}")
798        } else {
799            format!("Displaying Raw {sep} {render_link} {sep} {download_link}")
800        })
801    } else {
802        Some(format!(
803            "Displaying Raw {} {download_link}",
804            icon_sep(config)
805        ))
806    };
807    let content = if !blob.displayable {
808        crate::highlight::plain_literal_block(&blob.content)
809    } else if render {
810        match renderable {
811            Some(RenderableBlob::Markdown) => markdown_to_micron_scoped(
812                &blob.content,
813                Some(&markdown_blob_url_scope(&group, &repo, reference, &path)),
814            ),
815            Some(RenderableBlob::Micron) => {
816                let mut out = blob.content.clone();
817                if !out.ends_with('\n') {
818                    out.push('\n');
819                }
820                out
821            }
822            None => crate::highlight::literal_block(&blob.content, Some(&path), None),
823        }
824    } else {
825        crate::highlight::literal_block(&blob.content, Some(&path), None)
826    };
827    let controls = controls
828        .map(|controls| format!("{controls}\n\n"))
829        .unwrap_or_default();
830    Ok(format!(
831        ">>\n{} / {} / {} / {}\n\n>{} `F666{} ({}, {})`f\n\n{}{}",
832        m_link("Node", PATH_INDEX, &[]),
833        m_link(&group, PATH_GROUP, &[("g", &group)]),
834        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
835        m_escape(&path),
836        m_escape(&path),
837        &resolved[..8],
838        if blob.binary { "Binary" } else { "Text" },
839        format_size(blob.size),
840        controls,
841        content
842    ))
843}
844
845#[derive(Debug, Clone, Copy)]
846enum RenderableBlob {
847    Markdown,
848    Micron,
849}
850
851fn renderable_blob(path: &str) -> Option<RenderableBlob> {
852    match Path::new(path)
853        .extension()
854        .and_then(|extension| extension.to_str())
855        .map(str::to_ascii_lowercase)
856        .as_deref()
857    {
858        Some("md") => Some(RenderableBlob::Markdown),
859        Some("mu") => Some(RenderableBlob::Micron),
860        _ => None,
861    }
862}
863
864fn render_by_default(path: &str) -> bool {
865    renderable_blob(path).is_some()
866}
867
868fn render_commits_page(
869    config: &ServerConfig,
870    access: &Access,
871    remote: Option<&[u8; 16]>,
872    vars: &BTreeMap<String, String>,
873) -> Result<String> {
874    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
875    let reference = var(vars, "ref").unwrap_or("HEAD");
876    let resolved = resolve_ref(&repository, reference)?;
877    let commits = commits(&repository, &resolved, 100)?;
878
879    let mut out = format!(
880        ">>\n{} / {} / {} / commits\n\n>Commits `F666{} ({})`f\n\n",
881        m_link("Node", PATH_INDEX, &[]),
882        m_link(&group, PATH_GROUP, &[("g", &group)]),
883        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
884        m_escape(reference),
885        &resolved[..8]
886    );
887    if commits.is_empty() {
888        out.push_str("No commits found.\n");
889    } else {
890        for commit in commits {
891            out.push_str(&format!(
892                "{} {} `F666{}`f\n{}\n\n",
893                m_link_raw(
894                    &commit.hash[..7],
895                    PATH_COMMIT,
896                    &[
897                        ("g", &group),
898                        ("r", &repo),
899                        ("ref", reference),
900                        ("h", &commit.hash),
901                    ],
902                ),
903                m_escape(&commit.author),
904                format_unix_time(commit.timestamp),
905                m_escape(&commit.subject)
906            ));
907        }
908    }
909    Ok(out)
910}
911
912fn render_commit_page(
913    config: &ServerConfig,
914    access: &Access,
915    remote: Option<&[u8; 16]>,
916    vars: &BTreeMap<String, String>,
917) -> Result<String> {
918    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
919    let reference = var(vars, "ref").unwrap_or("HEAD");
920    let _resolved = resolve_ref(&repository, reference)?;
921    let hash = required_var(vars, "h")?;
922    validate_refish(hash)?;
923    let commit = commit_info(&repository, hash)?;
924    let signature = commit_signature_status(&repository, hash)?;
925    let commits_link = m_link(
926        "commits",
927        PATH_COMMITS,
928        &[("g", &group), ("r", &repo), ("ref", reference)],
929    );
930    let mut out = format!(
931        ">>\n{} / {} / {} / {} / commit {}\n\n>Commit {}\n\n{}\nAuthor: {} <{}>\nDate: {}\n",
932        m_link("Node", PATH_INDEX, &[]),
933        m_link(&group, PATH_GROUP, &[("g", &group)]),
934        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
935        commits_link,
936        &commit.hash[..7],
937        commit.hash,
938        m_escape(&commit.subject),
939        m_escape(&commit.author),
940        m_escape(&commit.email),
941        format_unix_time(commit.timestamp)
942    );
943    if let Some(signature) = signature {
944        out.push_str(&format!("Signature: {}\n", m_escape(&signature)));
945    }
946    if !commit.body.is_empty() {
947        out.push_str(&format!("\n{}\n", format_commit_message(&commit.body)));
948    }
949    out.push_str("\n>Files changed\n\n");
950    for file in commit.files {
951        out.push_str(&format!("{} {}\n", file.status, m_escape(&file.path)));
952    }
953    Ok(out)
954}
955
956fn commit_signature_status(repo: &Path, hash: &str) -> Result<Option<String>> {
957    let commit = raw_commit_object(repo, hash)?;
958    let (Some(signature), signed_content) = crate::commitsigs::split_commit_signature(&commit)
959    else {
960        return Ok(None);
961    };
962    let status = crate::commitsigs::verify_message_signature(&signature, &signed_content, None);
963    if status.valid && status.author_match {
964        Ok(Some("Valid, signed by author".into()))
965    } else if status.valid {
966        Ok(Some(status.message))
967    } else {
968        Ok(Some(format!("Invalid signature: {}", status.message)))
969    }
970}
971
972fn render_refs_page(
973    config: &ServerConfig,
974    access: &Access,
975    remote: Option<&[u8; 16]>,
976    vars: &BTreeMap<String, String>,
977) -> Result<String> {
978    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
979    let refs = git_refs(&repository)?;
980    let mut out = format!(
981        ">>\n{} / {} / {} / refs\n\n>Refs\n\n>Branches\n",
982        m_link("Node", PATH_INDEX, &[]),
983        m_link(&group, PATH_GROUP, &[("g", &group)]),
984        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)])
985    );
986    for branch in refs.heads {
987        out.push_str(&format!(
988            "- {} `F666{}`f\n",
989            m_link_raw(
990                &branch.name,
991                PATH_TREE,
992                &[("g", &group), ("r", &repo), ("ref", &branch.name)]
993            ),
994            &branch.sha[..8]
995        ));
996    }
997    out.push_str("\n>Tags\n");
998    for tag in refs.tags {
999        out.push_str(&format!(
1000            "- {} `F666{}`f\n",
1001            m_link_raw(
1002                &tag.name,
1003                PATH_TREE,
1004                &[("g", &group), ("r", &repo), ("ref", &tag.name)]
1005            ),
1006            &tag.sha[..8]
1007        ));
1008    }
1009    Ok(out)
1010}
1011
1012fn render_stats_page(
1013    config: &ServerConfig,
1014    access: &Access,
1015    remote: Option<&[u8; 16]>,
1016    vars: &BTreeMap<String, String>,
1017) -> Result<String> {
1018    let (group, repo, _repository) = accessible_repository(config, access, remote, vars)?;
1019    if !access.allows(Operation::Stats, &format!("{group}/{repo}"), remote)? {
1020        return Ok(">>\n>Error\n\nThe requested repository was not found.\n".to_string());
1021    }
1022    let Some(stats) = crate::stats::repository_stats(config, access, remote, &group, &repo, 90)?
1023    else {
1024        return Ok(
1025            ">>\n>Stats Unavailable\n\nCould not retrieve statistics for this repository.\n"
1026                .to_string(),
1027        );
1028    };
1029
1030    let mut out = format!(
1031        ">>\n{} / {} / {} / stats\n\n>Stats for {}\n\n",
1032        m_link("Node", PATH_INDEX, &[]),
1033        m_link(&group, PATH_GROUP, &[("g", &group)]),
1034        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
1035        m_escape(&stats.repository)
1036    );
1037    out.push_str(&format!(
1038        "`FT{RCLR_FETCH}Fetches`f   : {:>5}  total `F666  today: {:>3}  peak: {:>3} `f\n",
1039        stats.fetches.total,
1040        stats.fetches.daily.last().copied().unwrap_or(0),
1041        stats.fetches.peak
1042    ));
1043    out.push_str(&format!(
1044        "`FT{RCLR_PUSH}Pushes`f    : {:>5}  total `F666  today: {:>3}  peak: {:>3} `f\n",
1045        stats.pushes.total,
1046        stats.pushes.daily.last().copied().unwrap_or(0),
1047        stats.pushes.peak
1048    ));
1049    out.push_str(&format!(
1050        "`FT{RCLR_VIEW}Views`f     : {:>5}  total `F666  today: {:>3}  peak: {:>3} `f\n",
1051        stats.views.total,
1052        stats.views.daily.last().copied().unwrap_or(0),
1053        stats.views.peak
1054    ));
1055    out.push_str(&format!(
1056        "`FT{RCLR_DOWNLOAD}Downloads`f : {:>5}  total `F666  today: {:>3}  peak: {:>3} `f\n",
1057        stats.downloads_combined.total,
1058        stats.downloads_combined.daily.last().copied().unwrap_or(0),
1059        stats.downloads_combined.peak
1060    ));
1061    out.push_str(&format!(
1062        "`F0aaActivity`f  : {:>5} points\n\n",
1063        stats.activity_score
1064    ));
1065    out.push_str(&format!(
1066        "{}{}`f over the last {} days ({})\n\n",
1067        stats.activity_level.color(),
1068        stats.activity_level.label(),
1069        stats.actual_days,
1070        stats.date_range
1071    ));
1072
1073    if stats.fetches.total > 0 {
1074        out.push_str(">Fetches\n\n");
1075        out.push_str(&render_chart(
1076            &stats.fetches.daily,
1077            &stats.timeline_labels,
1078            RCLR_FETCH,
1079            Some(RCLR_FETCH_G),
1080            1.3,
1081            10,
1082        ));
1083        out.push('\n');
1084    }
1085    if stats.pushes.total > 0 {
1086        out.push_str(">Pushes\n\n");
1087        out.push_str(&render_chart(
1088            &stats.pushes.daily,
1089            &stats.timeline_labels,
1090            RCLR_PUSH,
1091            Some(RCLR_PUSH_G),
1092            1.3,
1093            10,
1094        ));
1095        out.push('\n');
1096    }
1097    if stats.views.total > 0 {
1098        out.push_str(">Views\n\n");
1099        out.push_str(&render_chart(
1100            &stats.views.daily,
1101            &stats.timeline_labels,
1102            RCLR_VIEW,
1103            Some(RCLR_VIEW_G),
1104            1.3,
1105            10,
1106        ));
1107        out.push('\n');
1108    }
1109    if stats.downloads_combined.total > 0 {
1110        out.push_str(">Downloads\n\n");
1111        out.push_str(&render_chart(
1112            &stats.downloads_combined.daily,
1113            &stats.timeline_labels,
1114            RCLR_DOWNLOAD,
1115            Some(RCLR_DOWNLOAD_G),
1116            1.7,
1117            10,
1118        ));
1119        out.push('\n');
1120    }
1121    if stats.activity_score > 0 {
1122        out.push_str(">Combined Activity\n\n");
1123        out.push_str(&render_combined_chart(
1124            &stats.views.daily,
1125            &stats.fetches.daily,
1126            &stats.pushes.daily,
1127            &stats.downloads_combined.daily,
1128            &stats.timeline_labels,
1129            6,
1130        ));
1131    } else {
1132        out.push_str(
1133            "`*\nNo development activity recorded for this repository in the selected time period.\n\n`*",
1134        );
1135    }
1136    Ok(out)
1137}
1138
1139fn render_releases_page(
1140    config: &ServerConfig,
1141    access: &Access,
1142    remote: Option<&[u8; 16]>,
1143    vars: &BTreeMap<String, String>,
1144) -> Result<String> {
1145    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
1146    let releases_path = crate::release::release_sidecar_path(&repository);
1147    let releases: Vec<_> = crate::release::list_releases(&releases_path)?
1148        .into_iter()
1149        .filter(|release| release.status == "published")
1150        .collect();
1151    let latest_release = crate::release::configured_latest_tag(&releases_path)?;
1152    let mut out = format!(
1153        ">>\n{} / {} / {} / releases\n\n>Releases ({})\n\n",
1154        m_link("Node", PATH_INDEX, &[]),
1155        m_link(&group, PATH_GROUP, &[("g", &group)]),
1156        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
1157        releases.len()
1158    );
1159    if releases.is_empty() {
1160        out.push_str("No releases available for this repository.\n");
1161        return Ok(out);
1162    }
1163    for release in releases {
1164        let latest = if latest_release.as_deref() == Some(release.tag.as_str()) {
1165            format!(" {} `FT537855`*Latest`*`f", icon_sep(config))
1166        } else {
1167            String::new()
1168        };
1169        out.push_str(&format!(
1170            "{} `F666{} artifacts{}`f\n",
1171            m_link(
1172                &release.tag,
1173                PATH_RELEASE,
1174                &[("g", &group), ("r", &repo), ("tag", &release.tag)]
1175            ),
1176            release.artifacts,
1177            latest
1178        ));
1179        if !release.preview.is_empty() {
1180            append_release_preview(&mut out, &release.preview_format, &release.preview);
1181        }
1182        out.push('\n');
1183    }
1184    Ok(out)
1185}
1186
1187fn append_release_preview(out: &mut String, format: &str, preview: &str) {
1188    match format {
1189        "markdown" => out.push_str(&markdown_to_micron(preview)),
1190        "micron" => {
1191            out.push_str(preview);
1192            if !preview.ends_with('\n') {
1193                out.push('\n');
1194            }
1195        }
1196        _ => {
1197            out.push_str(&m_escape_line_start_controls(preview));
1198            out.push('\n');
1199        }
1200    }
1201}
1202
1203fn render_release_page(
1204    config: &ServerConfig,
1205    access: &Access,
1206    remote: Option<&[u8; 16]>,
1207    vars: &BTreeMap<String, String>,
1208) -> Result<String> {
1209    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
1210    let releases_path = crate::release::release_sidecar_path(&repository);
1211    let requested_tag = var(vars, "tag").unwrap_or("latest");
1212    let tag = if requested_tag == "latest" {
1213        let Some(tag) = crate::release::latest_published_tag(&releases_path)? else {
1214            return Ok(">Release Not Found\n\nNo latest release exists.\n".to_string());
1215        };
1216        tag
1217    } else {
1218        requested_tag.to_string()
1219    };
1220    let Some(release) = crate::release::release_data(&releases_path, &tag)? else {
1221        return Ok(">Release Not Found\n\nThe requested release was not found.\n".to_string());
1222    };
1223    if release.status != "published" {
1224        return Ok(">Release Not Found\n\nThe requested release was not found.\n".to_string());
1225    }
1226    let release_dir = releases_path.join(&release.tag);
1227    let thanks = crate::release::release_thanks(&release_dir, var(vars, "thanks").is_some())?;
1228    let mut out = format!(
1229        ">>\n{} / {} / {} / {} / {}\n\n>Release {}\n\n",
1230        m_link("Node", PATH_INDEX, &[]),
1231        m_link(&group, PATH_GROUP, &[("g", &group)]),
1232        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
1233        m_link("releases", PATH_RELEASES, &[("g", &group), ("r", &repo)]),
1234        m_escape(&release.tag),
1235        m_escape(&release.tag)
1236    );
1237    out.push_str(&format!(
1238        "{}\n\n",
1239        m_link(
1240            &icon_label(config, "heart", &format!("Thanks ({thanks})")),
1241            PATH_RELEASE,
1242            &[
1243                ("g", &group),
1244                ("r", &repo),
1245                ("tag", &release.tag),
1246                ("thanks", "y")
1247            ]
1248        )
1249    ));
1250    if !release.notes.is_empty() {
1251        match release.notes_format.as_str() {
1252            "markdown" => out.push_str(&markdown_to_micron(&release.notes)),
1253            "micron" => out.push_str(&release.notes),
1254            _ => out.push_str(&m_escape(&release.notes)),
1255        }
1256        if !out.ends_with('\n') {
1257            out.push('\n');
1258        }
1259        out.push('\n');
1260    }
1261    out.push_str(">Artifacts\n\n");
1262    if release.artifacts.is_empty() {
1263        out.push_str("`*No artifacts for this release`*\n");
1264    } else {
1265        for artifact in release.artifacts {
1266            out.push_str(&format!(
1267                "{} ({})\n",
1268                m_link(
1269                    &artifact.name,
1270                    PATH_DOWNLOAD,
1271                    &[
1272                        ("g", &group),
1273                        ("r", &repo),
1274                        ("tag", &release.tag),
1275                        ("artifact", &artifact.name)
1276                    ]
1277                ),
1278                format_size(artifact.size)
1279            ));
1280        }
1281    }
1282    Ok(out)
1283}
1284
1285fn render_work_page(
1286    config: &ServerConfig,
1287    access: &Access,
1288    remote: Option<&[u8; 16]>,
1289    vars: &BTreeMap<String, String>,
1290) -> Result<String> {
1291    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
1292    let scope = var(vars, "scope").unwrap_or("active");
1293    let list_scope =
1294        crate::work::WorkListScope::parse(scope).ok_or_else(|| Error::msg("invalid scope"))?;
1295    let lists =
1296        crate::work::list_documents(&crate::work::work_sidecar_path(&repository), list_scope)?;
1297    let mut out = format!(
1298        ">>\n{} / {} / {} / work\n\n>Work Documents\n\n",
1299        m_link("Node", PATH_INDEX, &[]),
1300        m_link(&group, PATH_GROUP, &[("g", &group)]),
1301        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)])
1302    );
1303    let tabs = [
1304        m_link_raw(
1305            "Active",
1306            PATH_WORK,
1307            &[("g", &group), ("r", &repo), ("scope", "active")],
1308        ),
1309        m_link_raw(
1310            "Completed",
1311            PATH_WORK,
1312            &[("g", &group), ("r", &repo), ("scope", "completed")],
1313        ),
1314        m_link_raw(
1315            "Proposed",
1316            PATH_WORK,
1317            &[("g", &group), ("r", &repo), ("scope", "proposed")],
1318        ),
1319        m_link_raw(
1320            "All",
1321            PATH_WORK,
1322            &[("g", &group), ("r", &repo), ("scope", "all")],
1323        ),
1324    ];
1325    out.push_str(&format!(
1326        "{}\n\n",
1327        tabs.join(&format!(" {} ", icon_sep(config)))
1328    ));
1329    if matches!(
1330        list_scope,
1331        crate::work::WorkListScope::Active | crate::work::WorkListScope::All
1332    ) {
1333        append_work_section(
1334            &mut out,
1335            &group,
1336            &repo,
1337            crate::work::WorkScope::Active,
1338            &lists.active,
1339        );
1340    }
1341    if matches!(
1342        list_scope,
1343        crate::work::WorkListScope::Completed | crate::work::WorkListScope::All
1344    ) {
1345        append_work_section(
1346            &mut out,
1347            &group,
1348            &repo,
1349            crate::work::WorkScope::Completed,
1350            &lists.completed,
1351        );
1352    }
1353    if matches!(
1354        list_scope,
1355        crate::work::WorkListScope::Proposed | crate::work::WorkListScope::All
1356    ) {
1357        append_work_section(
1358            &mut out,
1359            &group,
1360            &repo,
1361            crate::work::WorkScope::Proposed,
1362            &lists.proposed,
1363        );
1364    }
1365    Ok(out)
1366}
1367
1368fn append_work_section(
1369    out: &mut String,
1370    group: &str,
1371    repo: &str,
1372    scope: crate::work::WorkScope,
1373    docs: &[crate::work::WorkSummary],
1374) {
1375    let title = match scope {
1376        crate::work::WorkScope::Active => "Active Work Documents",
1377        crate::work::WorkScope::Completed => "Completed Work Documents",
1378        crate::work::WorkScope::Proposed => "Proposed Work Documents",
1379    };
1380    out.push_str(&format!(">{title} ({})\n\n", docs.len()));
1381    if docs.is_empty() {
1382        out.push_str("No work documents found.\n\n");
1383        return;
1384    }
1385    for doc in docs {
1386        let doc_id = doc.id.to_string();
1387        out.push_str(&format!(
1388            "{} `F666{} updates`f\n",
1389            m_link(
1390                &format!("#{} {}", doc.id, doc.title),
1391                PATH_WORK_DOC,
1392                &[
1393                    ("g", group),
1394                    ("r", repo),
1395                    ("scope", scope.as_str()),
1396                    ("id", &doc_id)
1397                ]
1398            ),
1399            doc.comments
1400        ));
1401        out.push_str(&format!(
1402            "`F666{} by {}`f\n\n",
1403            format_unix_time(doc.created as i64),
1404            m_escape(&doc.author)
1405        ));
1406    }
1407}
1408
1409fn render_work_doc_page(
1410    config: &ServerConfig,
1411    access: &Access,
1412    remote: Option<&[u8; 16]>,
1413    vars: &BTreeMap<String, String>,
1414) -> Result<String> {
1415    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
1416    let id = required_var(vars, "id")?
1417        .parse::<u64>()
1418        .map_err(|_| Error::msg("invalid work document ID"))?;
1419    let work_path = crate::work::work_sidecar_path(&repository);
1420    let (scope, document) =
1421        resolve_work_document(&work_path, var(vars, "scope").unwrap_or("all"), id)?;
1422    let Some(document) = document else {
1423        return Ok(
1424            ">Work Document Not Found\n\nThe requested work document was not found.\n".into(),
1425        );
1426    };
1427    let mut out = format!(
1428        ">>\n{} / {} / {} / {} / #{}\n\n>{}\n\n",
1429        m_link("Node", PATH_INDEX, &[]),
1430        m_link(&group, PATH_GROUP, &[("g", &group)]),
1431        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
1432        m_link(
1433            "work",
1434            PATH_WORK,
1435            &[("g", &group), ("r", &repo), ("scope", scope.as_str())]
1436        ),
1437        document.id,
1438        m_escape(&document.title)
1439    );
1440    out.push_str(&format!(
1441        "`F666Status: {} | Author: {} | Signature: {} | Created: {} | Edited: {}`f\n\n",
1442        scope.as_str(),
1443        m_escape(&document.author),
1444        work_signature_status(&document),
1445        format_unix_time(document.created as i64),
1446        format_unix_time(document.edited as i64)
1447    ));
1448    let doc_id = document.id.to_string();
1449    out.push_str(&format!(
1450        "{}\n\n",
1451        m_link(
1452            "Download",
1453            PATH_WORK_DOC_DOWNLOAD,
1454            &[
1455                ("g", &group),
1456                ("r", &repo),
1457                ("scope", scope.as_str()),
1458                ("id", &doc_id)
1459            ]
1460        )
1461    ));
1462    append_formatted_content(&mut out, &document.format, &document.content);
1463    out.push_str(&format!("\n>Updates ({})\n\n", document.comments.len()));
1464    if document.comments.is_empty() {
1465        out.push_str("No updates for this work document.\n");
1466    } else {
1467        for comment in document.comments {
1468            out.push_str(&format!(
1469                ">#{} by {} at {}\n\n",
1470                comment.id,
1471                m_escape(&comment.author),
1472                format_unix_time(comment.created as i64)
1473            ));
1474            append_formatted_content(&mut out, &comment.format, &comment.content);
1475            out.push('\n');
1476        }
1477    }
1478    Ok(out)
1479}
1480
1481fn work_signature_status(document: &crate::work::WorkDocument) -> &'static str {
1482    let Some(signature) = document.signature.as_deref() else {
1483        return "Document not signed";
1484    };
1485    if signature.len() != 64 {
1486        return "Not valid";
1487    }
1488    let Ok(signature) = <&[u8; 64]>::try_from(signature) else {
1489        return "Not valid";
1490    };
1491    let Some(identity_bytes) = document.identity.as_deref() else {
1492        return "Not valid";
1493    };
1494    let Ok(public_key) = <[u8; 64]>::try_from(identity_bytes) else {
1495        return "Not valid";
1496    };
1497    let identity = Identity::from_public_key(&public_key);
1498    if identity.verify(signature, document.content.as_bytes()) {
1499        "Valid"
1500    } else {
1501        "Not valid"
1502    }
1503}
1504
1505fn resolve_work_document(
1506    work_path: &Path,
1507    requested_scope: &str,
1508    id: u64,
1509) -> Result<(crate::work::WorkScope, Option<crate::work::WorkDocument>)> {
1510    let requested_scope = if matches!(requested_scope, "active" | "completed" | "proposed" | "all")
1511    {
1512        requested_scope
1513    } else {
1514        "active"
1515    };
1516    match requested_scope {
1517        "active" | "completed" | "proposed" => {
1518            let scope = crate::work::WorkScope::parse(requested_scope).unwrap();
1519            Ok((scope, crate::work::view_document(work_path, scope, id)?))
1520        }
1521        "all" => {
1522            if let Some(document) =
1523                crate::work::view_document(work_path, crate::work::WorkScope::Active, id)?
1524            {
1525                Ok((crate::work::WorkScope::Active, Some(document)))
1526            } else if let Some(document) =
1527                crate::work::view_document(work_path, crate::work::WorkScope::Completed, id)?
1528            {
1529                Ok((crate::work::WorkScope::Completed, Some(document)))
1530            } else {
1531                Ok((
1532                    crate::work::WorkScope::Proposed,
1533                    crate::work::view_document(work_path, crate::work::WorkScope::Proposed, id)?,
1534                ))
1535            }
1536        }
1537        _ => unreachable!(),
1538    }
1539}
1540
1541fn append_formatted_content(out: &mut String, format: &str, content: &str) {
1542    match format {
1543        "micron" => out.push_str(content),
1544        "markdown" => out.push_str(&markdown_to_micron(content)),
1545        _ => out.push_str(&m_escape(content)),
1546    }
1547    if !out.ends_with('\n') {
1548        out.push('\n');
1549    }
1550}
1551
1552pub fn download_work_document(
1553    config: &ServerConfig,
1554    access: &Access,
1555    data: &[u8],
1556    remote: Option<&[u8; 16]>,
1557) -> Result<RequestResponse> {
1558    let vars = parse_page_vars(data)?;
1559    let (group, repo, repository) = match accessible_repository(config, access, remote, &vars) {
1560        Ok(repo) => repo,
1561        Err(_) => {
1562            return Ok(RequestResponse::Bytes(protocol::status_bytes(
1563                protocol::RES_NOT_FOUND,
1564                b"repository not found",
1565            )));
1566        }
1567    };
1568    let id = required_var(&vars, "id")?
1569        .parse::<u64>()
1570        .map_err(|_| Error::msg("invalid work document ID"))?;
1571    let work_path = crate::work::work_sidecar_path(&repository);
1572    let Some(document) =
1573        resolve_work_document(&work_path, var(&vars, "scope").unwrap_or("all"), id)?.1
1574    else {
1575        return Ok(RequestResponse::Bytes(protocol::status_bytes(
1576            protocol::RES_NOT_FOUND,
1577            b"work document not found",
1578        )));
1579    };
1580    crate::stats::record_download(config, &format!("{group}/{repo}"), remote);
1581    Ok(RequestResponse::Resource {
1582        data: document.content.into_bytes(),
1583        metadata: Some(protocol::metadata_status(protocol::RES_OK)),
1584        auto_compress: true,
1585    })
1586}
1587
1588pub fn download_file(
1589    config: &ServerConfig,
1590    access: &Access,
1591    data: &[u8],
1592    remote: Option<&[u8; 16]>,
1593) -> Result<RequestResponse> {
1594    let vars = parse_page_vars(data)?;
1595    let (group, repo, repository) = match accessible_repository(config, access, remote, &vars) {
1596        Ok(repo) => repo,
1597        Err(_) => {
1598            return Ok(RequestResponse::Bytes(protocol::status_bytes(
1599                protocol::RES_NOT_FOUND,
1600                b"repository not found",
1601            )));
1602        }
1603    };
1604    if let Some(artifact) = var(&vars, "artifact") {
1605        let artifact = decode_url_component(artifact);
1606        let tag = var(&vars, "tag").unwrap_or("latest");
1607        let releases_path = crate::release::release_sidecar_path(&repository);
1608        let Some(path) = crate::release::artifact_path(&releases_path, tag, &artifact)? else {
1609            return Ok(RequestResponse::Bytes(protocol::status_bytes(
1610                protocol::RES_NOT_FOUND,
1611                b"file not found",
1612            )));
1613        };
1614        let data = fs::read(path)?;
1615        crate::stats::record_release_download(config, &format!("{group}/{repo}"), remote);
1616        return Ok(RequestResponse::Resource {
1617            data,
1618            metadata: Some(protocol::metadata_status(protocol::RES_OK)),
1619            auto_compress: true,
1620        });
1621    }
1622
1623    let reference = var(&vars, "ref").unwrap_or("HEAD");
1624    let path = required_var(&vars, "path")?;
1625    validate_git_path(path)?;
1626    let resolved = resolve_ref(&repository, reference)?;
1627    let spec = format!("{resolved}:{path}");
1628    let output = run_git_output(
1629        Command::new("git")
1630            .arg("--git-dir")
1631            .arg(&repository)
1632            .arg("show")
1633            .arg(spec),
1634        GIT_COMMAND_TIMEOUT,
1635    )?;
1636    if !output.status.success() {
1637        return Ok(RequestResponse::Bytes(protocol::status_bytes(
1638            protocol::RES_NOT_FOUND,
1639            b"file not found",
1640        )));
1641    }
1642    crate::stats::record_download(config, &format!("{group}/{repo}"), remote);
1643    Ok(RequestResponse::Resource {
1644        data: output.stdout,
1645        metadata: Some(protocol::metadata_status(protocol::RES_OK)),
1646        auto_compress: true,
1647    })
1648}
1649
1650fn render_chart(
1651    data: &[u64],
1652    labels: &[String; 2],
1653    color: &str,
1654    secondary_color: Option<&str>,
1655    gradient_factor: f64,
1656    height: u64,
1657) -> String {
1658    if data.is_empty() || data.iter().all(|value| *value == 0) {
1659        return "No data available\n".to_string();
1660    }
1661    let max = data.iter().copied().max().unwrap_or(1).max(1) as f64;
1662    let primary = expand_chart_color(color);
1663    let secondary = secondary_color
1664        .map(expand_chart_color)
1665        .unwrap_or_else(|| dim_chart_color(&primary));
1666    let mut out = format!("`FT{primary}Peak: {}`f\n", max as u64);
1667    for row in (1..=height).rev() {
1668        let row_top = row as f64 / height as f64 * max;
1669        let row_mid = (row as f64 - 0.5) / height as f64 * max;
1670        let grad_top = row as f64 / height as f64;
1671        let grad_mid = (row as f64 - 0.5) / height as f64;
1672        let top_color = gradient_chart_color(&primary, &secondary, grad_top, gradient_factor);
1673        let mid_color = gradient_chart_color(&primary, &secondary, grad_mid, gradient_factor);
1674        out.push('│');
1675        for value in data {
1676            let value = *value as f64;
1677            if value >= row_top {
1678                out.push_str(&format!("`FT{top_color}`BT{mid_color}▀`f`b"));
1679            } else if value >= row_mid || (row == 1 && value > 0.0) {
1680                out.push_str(&format!("`FT{mid_color}▄`f"));
1681            } else {
1682                out.push(' ');
1683            }
1684        }
1685        out.push('\n');
1686    }
1687    out.push('└');
1688    for _ in data {
1689        out.push('─');
1690    }
1691    out.push_str("┘\n");
1692    out.push_str(&format!("`F666{:<12}`f", labels[0]));
1693    let chart_width = data.len() + 2;
1694    let spacing = chart_width.saturating_sub(24);
1695    out.push_str(&" ".repeat(spacing));
1696    out.push_str(&format!("`F666{:>12}`f\n", labels[1]));
1697    out
1698}
1699
1700fn expand_chart_color(color: &str) -> String {
1701    let color = color.trim_start_matches('#');
1702    if color.len() == 3 {
1703        color.chars().flat_map(|ch| [ch, ch]).collect()
1704    } else {
1705        color.chars().take(6).collect()
1706    }
1707}
1708
1709fn dim_chart_color(primary: &str) -> String {
1710    let rgb = chart_rgb(primary);
1711    format!(
1712        "{:02x}{:02x}{:02x}",
1713        (rgb[0] as f64 * 0.42) as u8,
1714        (rgb[1] as f64 * 0.42) as u8,
1715        (rgb[2] as f64 * 0.42) as u8
1716    )
1717}
1718
1719fn gradient_chart_color(primary: &str, secondary: &str, t: f64, factor: f64) -> String {
1720    let primary = chart_rgb(primary);
1721    let secondary = chart_rgb(secondary);
1722    let t = (t * factor).min(1.0);
1723    format!(
1724        "{:02x}{:02x}{:02x}",
1725        (secondary[0] as f64 + (primary[0] as f64 - secondary[0] as f64) * t) as u8,
1726        (secondary[1] as f64 + (primary[1] as f64 - secondary[1] as f64) * t) as u8,
1727        (secondary[2] as f64 + (primary[2] as f64 - secondary[2] as f64) * t) as u8
1728    )
1729}
1730
1731fn chart_rgb(color: &str) -> [u8; 3] {
1732    let color = expand_chart_color(color);
1733    [
1734        u8::from_str_radix(color.get(0..2).unwrap_or("00"), 16).unwrap_or(0),
1735        u8::from_str_radix(color.get(2..4).unwrap_or("00"), 16).unwrap_or(0),
1736        u8::from_str_radix(color.get(4..6).unwrap_or("00"), 16).unwrap_or(0),
1737    ]
1738}
1739
1740fn render_combined_chart(
1741    views: &[u64],
1742    fetches: &[u64],
1743    pushes: &[u64],
1744    downloads: &[u64],
1745    labels: &[String; 2],
1746    height: u64,
1747) -> String {
1748    if views.is_empty() || fetches.is_empty() || pushes.is_empty() || downloads.is_empty() {
1749        return "No data available\n".to_string();
1750    }
1751
1752    let categories = [
1753        ("Pushes", dim_to_black(RCLR_PUSH, 0.87), pushes),
1754        ("Fetches", dim_to_black(RCLR_FETCH, 0.87), fetches),
1755        ("Views", dim_to_black(RCLR_VIEW, 0.87), views),
1756        ("Downloads", dim_to_black(RCLR_DOWNLOAD, 0.87), downloads),
1757    ];
1758    let mut out = categories
1759        .iter()
1760        .map(|(label, color, _)| format!("`FT{color}`BT{color}██`f`b {label}"))
1761        .collect::<Vec<_>>()
1762        .join("  ");
1763    out.push_str("\n\n");
1764    let num_points = views.len();
1765
1766    for row in (1..=height).rev() {
1767        let lower_min = (row as f64 - 1.0) / height as f64;
1768        let lower_max = (row as f64 - 0.5) / height as f64;
1769        let upper_min = (row as f64 - 0.5) / height as f64;
1770        let upper_max = row as f64 / height as f64;
1771        out.push('│');
1772        for index in 0..num_points {
1773            let total: u64 = categories
1774                .iter()
1775                .map(|(_, _, data)| data.get(index).copied().unwrap_or(0))
1776                .sum();
1777            if total == 0 {
1778                out.push(' ');
1779                continue;
1780            }
1781
1782            let upper_cat = stacked_pixel_category(&categories, index, total, upper_min, upper_max);
1783            let lower_cat = stacked_pixel_category(&categories, index, total, lower_min, lower_max);
1784            match (upper_cat, lower_cat) {
1785                (Some(upper), Some(lower)) if upper == lower => {
1786                    let color = &categories[upper].1;
1787                    out.push_str(&format!("`FT{color}`BT{color}█`f`b"));
1788                }
1789                (Some(upper), Some(lower)) => {
1790                    let upper_color = &categories[upper].1;
1791                    let lower_color = &categories[lower].1;
1792                    out.push_str(&format!("`FT{upper_color}`BT{lower_color}▀`f`b"));
1793                }
1794                (Some(upper), None) => {
1795                    let color = &categories[upper].1;
1796                    out.push_str(&format!("`FT{color}▀`f"));
1797                }
1798                (None, Some(lower)) => {
1799                    let color = &categories[lower].1;
1800                    out.push_str(&format!("`FT{color}▄`f"));
1801                }
1802                (None, None) => out.push(' '),
1803            }
1804        }
1805        out.push('\n');
1806    }
1807    out.push('└');
1808    for _ in 0..num_points {
1809        out.push('─');
1810    }
1811    out.push_str("┘\n");
1812    out.push_str(&format!(
1813        "`F666{:<12}`f",
1814        labels[0].chars().take(12).collect::<String>()
1815    ));
1816    let chart_width = num_points + 2;
1817    let spacing = chart_width.saturating_sub(24);
1818    out.push_str(&" ".repeat(spacing));
1819    out.push_str(&format!(
1820        "`F666{:>12}`f\n",
1821        labels[1].chars().take(12).collect::<String>()
1822    ));
1823    out
1824}
1825
1826fn stacked_pixel_category(
1827    categories: &[(&str, String, &[u64])],
1828    index: usize,
1829    total: u64,
1830    pixel_min: f64,
1831    pixel_max: f64,
1832) -> Option<usize> {
1833    let mut cumulative = 0u64;
1834    for (category_index, (_, _, data)) in categories.iter().enumerate() {
1835        let value = data.get(index).copied().unwrap_or(0);
1836        if value == 0 {
1837            continue;
1838        }
1839        let start = cumulative as f64 / total as f64;
1840        cumulative += value;
1841        let end = cumulative as f64 / total as f64;
1842        if pixel_min < end && pixel_max > start {
1843            return Some(category_index);
1844        }
1845    }
1846    None
1847}
1848
1849fn dim_to_black(color: &str, dim: f64) -> String {
1850    gradient_chart_color(&expand_chart_color(color), "000000", dim, 1.0)
1851}
1852
1853fn accessible_groups(
1854    config: &ServerConfig,
1855    access: &Access,
1856    remote: Option<&[u8; 16]>,
1857) -> Result<BTreeMap<String, Vec<String>>> {
1858    let mut groups = BTreeMap::new();
1859    if !config.repositories_dir.exists() {
1860        return Ok(groups);
1861    }
1862    for group_entry in fs::read_dir(&config.repositories_dir)? {
1863        let group_entry = group_entry?;
1864        if !group_entry.file_type()?.is_dir() {
1865            continue;
1866        }
1867        let group = group_entry.file_name().to_string_lossy().to_string();
1868        if validate_group_name(&group).is_err() {
1869            continue;
1870        }
1871        let repos = accessible_repositories(config, access, remote, &group)?;
1872        if !repos.is_empty() {
1873            groups.insert(group, repos);
1874        }
1875    }
1876    Ok(groups)
1877}
1878
1879fn accessible_repositories(
1880    config: &ServerConfig,
1881    access: &Access,
1882    remote: Option<&[u8; 16]>,
1883    group: &str,
1884) -> Result<Vec<String>> {
1885    validate_group_name(group)?;
1886    let group_dir = config.repositories_dir.join(group);
1887    let mut repos = Vec::new();
1888    if !group_dir.exists() {
1889        return Ok(repos);
1890    }
1891    for repo_entry in fs::read_dir(group_dir)? {
1892        let repo_entry = repo_entry?;
1893        if !repo_entry.file_type()?.is_dir() {
1894            continue;
1895        }
1896        let repo = repo_entry.file_name().to_string_lossy().to_string();
1897        let Ok(name) = repository_name(group, &repo) else {
1898            continue;
1899        };
1900        if crate::git::is_bare_repository(&config.repositories_dir.join(&name))
1901            && access.allows(Operation::Read, &name, remote)?
1902        {
1903            repos.push(repo);
1904        }
1905    }
1906    repos.sort();
1907    Ok(repos)
1908}
1909
1910fn accessible_repository(
1911    config: &ServerConfig,
1912    access: &Access,
1913    remote: Option<&[u8; 16]>,
1914    vars: &BTreeMap<String, String>,
1915) -> Result<(String, String, PathBuf)> {
1916    let group = required_var(vars, "g")?;
1917    let repo = required_var(vars, "r")?;
1918    let name = repository_name(group, repo)?;
1919    let path = config.repositories_dir.join(&name);
1920    if !crate::git::is_bare_repository(&path) || !access.allows(Operation::Read, &name, remote)? {
1921        return Err(Error::msg("repository not found"));
1922    }
1923    Ok((group.to_string(), repo.to_string(), path))
1924}
1925
1926fn required_var<'a>(vars: &'a BTreeMap<String, String>, key: &str) -> Result<&'a str> {
1927    var(vars, key).ok_or_else(|| Error::msg(format!("missing page variable {key}")))
1928}
1929
1930fn decode_url_component(value: &str) -> String {
1931    let bytes = value.as_bytes();
1932    let mut out = Vec::with_capacity(bytes.len());
1933    let mut i = 0;
1934    while i < bytes.len() {
1935        match bytes[i] {
1936            b'+' => {
1937                out.push(b' ');
1938                i += 1;
1939            }
1940            b'%' if i + 2 < bytes.len() => {
1941                if let (Some(high), Some(low)) = (hex_value(bytes[i + 1]), hex_value(bytes[i + 2]))
1942                {
1943                    out.push((high << 4) | low);
1944                    i += 3;
1945                } else {
1946                    out.push(bytes[i]);
1947                    i += 1;
1948                }
1949            }
1950            byte => {
1951                out.push(byte);
1952                i += 1;
1953            }
1954        }
1955    }
1956    String::from_utf8_lossy(&out).into_owned()
1957}
1958
1959fn hex_value(byte: u8) -> Option<u8> {
1960    match byte {
1961        b'0'..=b'9' => Some(byte - b'0'),
1962        b'a'..=b'f' => Some(byte - b'a' + 10),
1963        b'A'..=b'F' => Some(byte - b'A' + 10),
1964        _ => None,
1965    }
1966}
1967
1968fn var<'a>(vars: &'a BTreeMap<String, String>, key: &str) -> Option<&'a str> {
1969    vars.get(&format!("var_{key}"))
1970        .or_else(|| vars.get(key))
1971        .map(String::as_str)
1972}
1973
1974fn repository_name(group: &str, repo: &str) -> Result<String> {
1975    validate_group_name(group)?;
1976    validate_repo_component(repo)?;
1977    let name = format!("{group}/{repo}");
1978    validate_repo_name(&name)?;
1979    Ok(name)
1980}
1981
1982fn validate_group_name(group: &str) -> Result<()> {
1983    validate_repo_component(group)
1984}
1985
1986fn validate_repo_component(component: &str) -> Result<()> {
1987    if component.is_empty()
1988        || component == "."
1989        || component == ".."
1990        || component.contains('/')
1991        || component.contains('\\')
1992        || component.len() > 256
1993    {
1994        Err(Error::msg("invalid repository name"))
1995    } else {
1996        Ok(())
1997    }
1998}
1999
2000fn validate_git_path(path: &str) -> Result<()> {
2001    if path.starts_with('/') || path.contains('\\') {
2002        return Err(Error::msg("invalid repository path"));
2003    }
2004    for component in path.split('/') {
2005        if component == "." || component == ".." {
2006            return Err(Error::msg("invalid repository path"));
2007        }
2008    }
2009    Ok(())
2010}
2011
2012fn normalize_blob_path(path: &str) -> String {
2013    path.strip_prefix("./").unwrap_or(path).replace("/./", "/")
2014}
2015
2016fn validate_refish(value: &str) -> Result<()> {
2017    if value.is_empty()
2018        || value.starts_with('-')
2019        || value.contains("..")
2020        || value.contains('\\')
2021        || value.contains(' ')
2022        || value.contains('~')
2023        || value.contains('^')
2024        || value.contains(':')
2025    {
2026        Err(Error::msg("invalid git ref"))
2027    } else {
2028        Ok(())
2029    }
2030}
2031
2032fn repository_description(repo: &Path) -> Result<String> {
2033    let path = repo.join("description");
2034    if !path.exists() {
2035        return Ok(String::new());
2036    }
2037    Ok(fs::read_to_string(path)?.trim().to_string())
2038}
2039
2040#[derive(Debug, Clone, PartialEq, Eq)]
2041struct RepositoryOrigin {
2042    kind: RepositoryOriginKind,
2043    source: String,
2044    synced_at: Option<u64>,
2045}
2046
2047#[derive(Debug, Clone, PartialEq, Eq)]
2048enum RepositoryOriginKind {
2049    Fork,
2050    Mirror,
2051}
2052
2053impl RepositoryOrigin {
2054    fn label(&self) -> &'static str {
2055        match self.kind {
2056            RepositoryOriginKind::Fork => "Forked",
2057            RepositoryOriginKind::Mirror => "Mirrored",
2058        }
2059    }
2060}
2061
2062fn repository_origin(repo: &Path) -> Result<Option<RepositoryOrigin>> {
2063    let kind_output = run_git_output(
2064        Command::new("git")
2065            .arg("--git-dir")
2066            .arg(repo)
2067            .arg("config")
2068            .arg("--get")
2069            .arg("repository.rngit.type"),
2070        GIT_COMMAND_TIMEOUT,
2071    )?;
2072    if !kind_output.status.success() {
2073        return Ok(None);
2074    }
2075    let kind = match String::from_utf8_lossy(&kind_output.stdout).trim() {
2076        "fork" => RepositoryOriginKind::Fork,
2077        "mirror" => RepositoryOriginKind::Mirror,
2078        _ => return Ok(None),
2079    };
2080    let source_output = run_git_output(
2081        Command::new("git")
2082            .arg("--git-dir")
2083            .arg(repo)
2084            .arg("config")
2085            .arg("--get")
2086            .arg("repository.rngit.upstream.source"),
2087        GIT_COMMAND_TIMEOUT,
2088    )?;
2089    if !source_output.status.success() {
2090        return Ok(None);
2091    }
2092    let source = String::from_utf8_lossy(&source_output.stdout)
2093        .trim()
2094        .to_string();
2095    if source.is_empty() {
2096        return Ok(None);
2097    }
2098    let synced_at = run_git_output(
2099        Command::new("git")
2100            .arg("--git-dir")
2101            .arg(repo)
2102            .arg("config")
2103            .arg("--get")
2104            .arg("repository.rngit.upstream.sync"),
2105        GIT_COMMAND_TIMEOUT,
2106    )
2107    .ok()
2108    .and_then(|output| output.status.success().then_some(output))
2109    .and_then(|output| {
2110        String::from_utf8_lossy(&output.stdout)
2111            .trim()
2112            .parse::<u64>()
2113            .ok()
2114    });
2115
2116    Ok(Some(RepositoryOrigin {
2117        kind,
2118        source,
2119        synced_at,
2120    }))
2121}
2122
2123fn format_sync_age(synced_at: u64) -> String {
2124    let now = SystemTime::now()
2125        .duration_since(UNIX_EPOCH)
2126        .unwrap_or_default()
2127        .as_secs();
2128    let seconds = now.saturating_sub(synced_at);
2129    if seconds < 60 {
2130        format!("{seconds}s")
2131    } else if seconds < 3_600 {
2132        format!("{}m", seconds / 60)
2133    } else if seconds < 86_400 {
2134        format!("{}h", seconds / 3_600)
2135    } else {
2136        format!("{}d", seconds / 86_400)
2137    }
2138}
2139
2140fn repository_thanks(repo: &Path, add: bool) -> Result<u64> {
2141    let path = repo.with_extension("thanks");
2142    let mut count = if path.exists() {
2143        fs::read_to_string(&path)?
2144            .trim()
2145            .parse::<u64>()
2146            .unwrap_or(0)
2147    } else {
2148        0
2149    };
2150    if add {
2151        count = count.saturating_add(1);
2152        fs::write(path, format!("{count}\n"))?;
2153    }
2154    Ok(count)
2155}
2156
2157#[derive(Debug)]
2158struct ReadmeContent {
2159    content: String,
2160    markdown: bool,
2161}
2162
2163fn readme_content(repo: &Path) -> Result<Option<ReadmeContent>> {
2164    const NAMES: &[&str] = &[
2165        "README.mu",
2166        "Readme.mu",
2167        "readme.mu",
2168        "README",
2169        "readme",
2170        "README.md",
2171        "readme.md",
2172        "README.rst",
2173        "README.txt",
2174        "readme.rst",
2175        "readme.txt",
2176    ];
2177    for name in NAMES {
2178        match run_git_output(
2179            Command::new("git")
2180                .arg("--git-dir")
2181                .arg(repo)
2182                .arg("show")
2183                .arg(format!("HEAD:{name}")),
2184            GIT_COMMAND_TIMEOUT,
2185        ) {
2186            Ok(output) if output.status.success() => {
2187                let content = String::from_utf8_lossy(&output.stdout).into_owned();
2188                let markdown = name.ends_with(".md");
2189                let content = if markdown || name.ends_with(".mu") {
2190                    content
2191                } else {
2192                    m_escape(&content)
2193                };
2194                return Ok(Some(ReadmeContent { content, markdown }));
2195            }
2196            Ok(_) => {}
2197            Err(err) => {
2198                log::warn!("Git command execution failed while reading README: {err}");
2199            }
2200        }
2201    }
2202    Ok(None)
2203}
2204
2205fn markdown_to_micron(input: &str) -> String {
2206    markdown_to_micron_scoped(input, None)
2207}
2208
2209fn markdown_to_micron_scoped(input: &str, url_scope: Option<&str>) -> String {
2210    let lines: Vec<&str> = input.lines().collect();
2211    let mut out = String::new();
2212    let mut code_block: Option<(Option<String>, String)> = None;
2213    let mut index = 0;
2214
2215    while index < lines.len() {
2216        let line = lines[index];
2217        let trimmed = line.trim();
2218        if trimmed.starts_with("```") {
2219            if let Some((language, content)) = code_block.take() {
2220                out.push_str(&markdown_code_block_to_micron(
2221                    language.as_deref(),
2222                    &content,
2223                ));
2224            } else {
2225                code_block = Some((markdown_fence_language(trimmed), String::new()));
2226            }
2227            index += 1;
2228            continue;
2229        }
2230        if let Some((_, content)) = code_block.as_mut() {
2231            content.push_str(line);
2232            content.push('\n');
2233            index += 1;
2234            continue;
2235        }
2236        if let Some(quote) = markdown_quote(line) {
2237            let mut parts = vec![quote.to_string()];
2238            index += 1;
2239            while index < lines.len() {
2240                if let Some(quote) = markdown_quote(lines[index]) {
2241                    parts.push(quote.to_string());
2242                    index += 1;
2243                } else {
2244                    break;
2245                }
2246            }
2247            let formatted = format_markdown_inline_scoped(&parts.join(" "), url_scope);
2248            for wrapped in markdown_wrap_text(&formatted, 77) {
2249                out.push_str(" │ ");
2250                out.push_str(&wrapped);
2251                out.push('\n');
2252            }
2253            continue;
2254        }
2255        if is_table_start(&lines, index) {
2256            let mut table = Vec::new();
2257            while index < lines.len() && lines[index].contains('|') {
2258                table.push(lines[index]);
2259                index += 1;
2260            }
2261            out.push_str(&format_markdown_table(&table, url_scope));
2262            continue;
2263        }
2264        if let Some((level, text)) = markdown_heading(line) {
2265            out.push_str(&">".repeat(level));
2266            out.push_str(&format_markdown_inline_scoped(text.trim(), url_scope));
2267            out.push('\n');
2268        } else if is_markdown_rule(trimmed) {
2269            out.push_str("-\n");
2270        } else if let Some((indent, text)) = unordered_list_item(line) {
2271            out.push_str(indent);
2272            out.push_str(" • ");
2273            out.push_str(&format_markdown_inline_scoped(text, url_scope));
2274            out.push('\n');
2275        } else if let Some((indent, number, text)) = ordered_list_item(line) {
2276            out.push_str(indent);
2277            out.push_str(number);
2278            out.push_str(". ");
2279            out.push_str(&format_markdown_inline_scoped(text, url_scope));
2280            out.push('\n');
2281        } else {
2282            out.push_str(&format_markdown_line(line, url_scope));
2283            out.push('\n');
2284        }
2285        index += 1;
2286    }
2287    if let Some((language, content)) = code_block {
2288        out.push_str(&markdown_code_block_to_micron(
2289            language.as_deref(),
2290            &content,
2291        ));
2292    }
2293
2294    out
2295}
2296
2297fn markdown_code_block_to_micron(language: Option<&str>, content: &str) -> String {
2298    if language.is_some_and(|language| language.eq_ignore_ascii_case("rawmu")) {
2299        return content.to_string();
2300    }
2301    crate::highlight::literal_block(content, None, language)
2302}
2303
2304fn markdown_fence_language(trimmed: &str) -> Option<String> {
2305    trimmed
2306        .strip_prefix("```")
2307        .and_then(|info| info.split_whitespace().next())
2308        .filter(|language| !language.is_empty())
2309        .map(str::to_string)
2310}
2311
2312fn markdown_quote(line: &str) -> Option<&str> {
2313    line.strip_prefix('>')
2314        .map(|quote| quote.strip_prefix(' ').unwrap_or(quote))
2315}
2316
2317fn markdown_heading(line: &str) -> Option<(usize, &str)> {
2318    let level = line.chars().take_while(|ch| *ch == '#').count();
2319    if (1..=6).contains(&level) && line.as_bytes().get(level) == Some(&b' ') {
2320        Some((level, &line[level + 1..]))
2321    } else {
2322        None
2323    }
2324}
2325
2326fn is_markdown_rule(trimmed: &str) -> bool {
2327    trimmed.len() >= 3
2328        && ["-", "*", "_", "="]
2329            .iter()
2330            .any(|marker| trimmed.chars().all(|ch| ch.to_string() == *marker))
2331}
2332
2333fn unordered_list_item(line: &str) -> Option<(&str, &str)> {
2334    let trimmed = line.trim_start();
2335    let indent = &line[..line.len() - trimmed.len()];
2336    for marker in ["- ", "* ", "+ "] {
2337        if let Some(text) = trimmed.strip_prefix(marker) {
2338            return Some((indent, text));
2339        }
2340    }
2341    None
2342}
2343
2344fn ordered_list_item(line: &str) -> Option<(&str, &str, &str)> {
2345    let trimmed = line.trim_start();
2346    let indent = &line[..line.len() - trimmed.len()];
2347    let (number, text) = trimmed.split_once(". ")?;
2348    if !number.is_empty() && number.chars().all(|ch| ch.is_ascii_digit()) {
2349        Some((indent, number, text))
2350    } else {
2351        None
2352    }
2353}
2354
2355fn format_markdown_line(line: &str, url_scope: Option<&str>) -> String {
2356    let formatted = format_markdown_inline_scoped(line, url_scope);
2357    if (line.starts_with('-') && !line.starts_with("---") && !line.starts_with("- "))
2358        || line.starts_with('<')
2359    {
2360        format!("\\{formatted}")
2361    } else {
2362        formatted
2363    }
2364}
2365
2366fn is_table_start(lines: &[&str], index: usize) -> bool {
2367    index + 1 < lines.len()
2368        && lines[index].contains('|')
2369        && lines[index + 1].contains('|')
2370        && is_table_separator(lines[index + 1])
2371}
2372
2373fn is_table_separator(line: &str) -> bool {
2374    parse_table_row(line)
2375        .iter()
2376        .all(|cell| !cell.is_empty() && cell.chars().all(|ch| matches!(ch, '-' | ':' | ' ')))
2377}
2378
2379#[derive(Clone, Copy)]
2380enum TableAlign {
2381    Left,
2382    Right,
2383    Center,
2384}
2385
2386fn format_markdown_table(lines: &[&str], url_scope: Option<&str>) -> String {
2387    if lines.len() < 2 {
2388        return String::new();
2389    }
2390    let header: Vec<String> = parse_table_row(lines[0])
2391        .into_iter()
2392        .map(|cell| format_markdown_inline_scoped(cell.trim(), url_scope))
2393        .collect();
2394    if header.is_empty() {
2395        return String::new();
2396    }
2397    let columns = header.len();
2398    let mut alignments = parse_table_alignments(lines[1]);
2399    while alignments.len() < columns {
2400        alignments.push(TableAlign::Left);
2401    }
2402    alignments.truncate(columns);
2403
2404    let rows: Vec<Vec<String>> = lines
2405        .iter()
2406        .skip(2)
2407        .map(|line| {
2408            let mut row: Vec<String> = parse_table_row(line)
2409                .into_iter()
2410                .map(|cell| format_markdown_inline_scoped(cell.trim(), url_scope))
2411                .collect();
2412            while row.len() < columns {
2413                row.push(String::new());
2414            }
2415            row.truncate(columns);
2416            row
2417        })
2418        .collect();
2419
2420    let mut widths = vec![3; columns];
2421    for (index, cell) in header.iter().enumerate() {
2422        widths[index] = widths[index].max(markdown_visible_width(cell));
2423    }
2424    for row in &rows {
2425        for (index, cell) in row.iter().enumerate().take(columns) {
2426            widths[index] = widths[index].max(markdown_visible_width(cell));
2427        }
2428    }
2429
2430    let mut out = String::new();
2431    out.push_str(&table_border('┌', '┬', '┐', &widths));
2432    out.push_str(&table_row(
2433        &header,
2434        &widths,
2435        &vec![TableAlign::Left; columns],
2436    ));
2437    out.push_str(&table_border('├', '┼', '┤', &widths));
2438    for row in rows {
2439        out.push_str(&table_row(&row, &widths, &alignments));
2440    }
2441    out.push_str(&table_border('└', '┴', '┘', &widths));
2442    out
2443}
2444
2445fn parse_table_row(line: &str) -> Vec<String> {
2446    let mut trimmed = line.trim();
2447    if let Some(rest) = trimmed.strip_prefix('|') {
2448        trimmed = rest;
2449    }
2450    if let Some(rest) = trimmed.strip_suffix('|') {
2451        trimmed = rest;
2452    }
2453    let mut cells = Vec::new();
2454    let mut current = String::new();
2455    let mut escaped = false;
2456    for ch in trimmed.chars() {
2457        if escaped {
2458            current.push(ch);
2459            escaped = false;
2460        } else if ch == '\\' {
2461            escaped = true;
2462        } else if ch == '|' {
2463            cells.push(current.trim().to_string());
2464            current.clear();
2465        } else {
2466            current.push(ch);
2467        }
2468    }
2469    if escaped {
2470        current.push('\\');
2471    }
2472    cells.push(current.trim().to_string());
2473    cells
2474}
2475
2476fn parse_table_alignments(line: &str) -> Vec<TableAlign> {
2477    parse_table_row(line)
2478        .into_iter()
2479        .map(|cell| {
2480            let cell = cell.trim();
2481            if cell.starts_with(':') && cell.ends_with(':') {
2482                TableAlign::Center
2483            } else if cell.ends_with(':') {
2484                TableAlign::Right
2485            } else {
2486                TableAlign::Left
2487            }
2488        })
2489        .collect()
2490}
2491
2492fn table_border(left: char, middle: char, right: char, widths: &[usize]) -> String {
2493    let mut out = String::new();
2494    out.push(left);
2495    for (index, width) in widths.iter().enumerate() {
2496        out.push_str(&"─".repeat(width + 2));
2497        if index + 1 == widths.len() {
2498            out.push(right);
2499        } else {
2500            out.push(middle);
2501        }
2502    }
2503    out.push('\n');
2504    out
2505}
2506
2507fn table_row(cells: &[String], widths: &[usize], alignments: &[TableAlign]) -> String {
2508    let mut out = String::from("│");
2509    for (index, width) in widths.iter().enumerate() {
2510        let cell = cells.get(index).map(String::as_str).unwrap_or("");
2511        out.push(' ');
2512        out.push_str(&pad_table_cell(
2513            cell,
2514            *width,
2515            alignments.get(index).copied().unwrap_or(TableAlign::Left),
2516        ));
2517        out.push(' ');
2518        out.push('│');
2519    }
2520    out.push('\n');
2521    out
2522}
2523
2524fn pad_table_cell(cell: &str, width: usize, alignment: TableAlign) -> String {
2525    let visible = markdown_visible_width(cell);
2526    let padding = width.saturating_sub(visible);
2527    match alignment {
2528        TableAlign::Left => format!("{cell}{}", " ".repeat(padding)),
2529        TableAlign::Right => format!("{}{cell}", " ".repeat(padding)),
2530        TableAlign::Center => {
2531            let left = padding / 2;
2532            let right = padding - left;
2533            format!("{}{cell}{}", " ".repeat(left), " ".repeat(right))
2534        }
2535    }
2536}
2537
2538fn markdown_visible_width(value: &str) -> usize {
2539    let mut width = 0;
2540    let mut chars = value.chars().peekable();
2541    while let Some(ch) = chars.next() {
2542        if ch == '`' {
2543            if matches!(chars.peek(), Some('F') | Some('B')) {
2544                chars.next();
2545                if matches!(chars.peek(), Some('T')) {
2546                    chars.next();
2547                    for _ in 0..6 {
2548                        chars.next();
2549                    }
2550                } else {
2551                    for _ in 0..3 {
2552                        chars.next();
2553                    }
2554                }
2555                continue;
2556            }
2557            if matches!(chars.peek(), Some('!') | Some('*') | Some('_') | Some('=')) {
2558                chars.next();
2559                continue;
2560            }
2561            if matches!(chars.peek(), Some('f') | Some('b') | Some('a')) {
2562                chars.next();
2563                continue;
2564            }
2565            if matches!(chars.peek(), Some('[')) {
2566                let mut label_width = 0;
2567                for link_ch in chars.by_ref() {
2568                    if link_ch == '`' {
2569                        for target_ch in chars.by_ref() {
2570                            if target_ch == ']' {
2571                                break;
2572                            }
2573                        }
2574                        break;
2575                    }
2576                    label_width += 1;
2577                }
2578                width += label_width;
2579                continue;
2580            }
2581        }
2582        width += 1;
2583    }
2584    width
2585}
2586
2587fn markdown_wrap_text(text: &str, width: usize) -> Vec<String> {
2588    if text.is_empty() {
2589        return vec![String::new()];
2590    }
2591    let mut lines = Vec::new();
2592    let mut current = String::new();
2593    let mut current_width = 0;
2594    for word in text.split_whitespace() {
2595        let word_width = markdown_visible_width(word);
2596        if !current.is_empty() && current_width + 1 + word_width > width {
2597            lines.push(current);
2598            current = String::new();
2599            current_width = 0;
2600        }
2601        if !current.is_empty() {
2602            current.push(' ');
2603            current_width += 1;
2604        }
2605        current.push_str(word);
2606        current_width += word_width;
2607    }
2608    if !current.is_empty() {
2609        lines.push(current);
2610    }
2611    if lines.is_empty() {
2612        lines.push(String::new());
2613    }
2614    lines
2615}
2616
2617#[derive(Debug)]
2618enum InlineToken {
2619    Link { label: String, target: String },
2620    Code(String),
2621}
2622
2623#[derive(Debug, Clone, Copy)]
2624struct MarkdownLinkStyle<'a> {
2625    bold: bool,
2626    underline: bool,
2627    color: Option<&'a str>,
2628}
2629
2630impl Default for MarkdownLinkStyle<'_> {
2631    fn default() -> Self {
2632        Self {
2633            bold: true,
2634            underline: true,
2635            color: None,
2636        }
2637    }
2638}
2639
2640fn format_markdown_inline_scoped(input: &str, url_scope: Option<&str>) -> String {
2641    let (with_placeholders, tokens) = extract_markdown_inline_tokens(input);
2642    let escaped = m_escape(&with_placeholders);
2643    let styled = apply_markdown_style(&escaped, "**", "`!", "`!");
2644    let styled = apply_markdown_style(&styled, "__", "`!", "`!");
2645    let styled = apply_markdown_style(&styled, "*", "`*", "`*");
2646    let styled = apply_markdown_style(&styled, "_", "`*", "`*");
2647    restore_markdown_inline_tokens(&styled, &tokens, url_scope)
2648}
2649
2650fn extract_markdown_inline_tokens(input: &str) -> (String, Vec<InlineToken>) {
2651    let mut out = String::new();
2652    let mut tokens = Vec::new();
2653    let mut index = 0;
2654
2655    while index < input.len() {
2656        let rest = &input[index..];
2657        if let Some(stripped) = rest.strip_prefix('`') {
2658            if let Some(end) = stripped.find('`') {
2659                let content = &stripped[..end];
2660                if is_markdown_inline_code(content) {
2661                    tokens.push(InlineToken::Code(content.to_string()));
2662                    out.push_str(&markdown_token_placeholder(tokens.len() - 1));
2663                    index += end + 2;
2664                    continue;
2665                }
2666            }
2667        }
2668        if rest.starts_with('[') {
2669            if let Some((consumed, label, target)) = parse_markdown_link(rest) {
2670                tokens.push(InlineToken::Link { label, target });
2671                out.push_str(&markdown_token_placeholder(tokens.len() - 1));
2672                index += consumed;
2673                continue;
2674            }
2675        }
2676        let Some(ch) = rest.chars().next() else {
2677            break;
2678        };
2679        out.push(ch);
2680        index += ch.len_utf8();
2681    }
2682
2683    (out, tokens)
2684}
2685
2686fn is_markdown_inline_code(content: &str) -> bool {
2687    !content.is_empty()
2688        && !content
2689            .chars()
2690            .next()
2691            .is_some_and(|ch| matches!(ch, '!' | '*' | '_' | '=' | 'F' | 'B' | 'f' | 'b' | 'a'))
2692}
2693
2694fn parse_markdown_link(input: &str) -> Option<(usize, String, String)> {
2695    let label_end = input[1..].find(']')? + 1;
2696    if !input[label_end + 1..].starts_with('(') {
2697        return None;
2698    }
2699    let target_start = label_end + 2;
2700    let target_end = input[target_start..].find(')')? + target_start;
2701    Some((
2702        target_end + 1,
2703        input[1..label_end].replace('`', ""),
2704        input[target_start..target_end].to_string(),
2705    ))
2706}
2707
2708fn markdown_token_placeholder(index: usize) -> String {
2709    format!("\u{1f}{index}\u{1f}")
2710}
2711
2712fn restore_markdown_inline_tokens(
2713    input: &str,
2714    tokens: &[InlineToken],
2715    url_scope: Option<&str>,
2716) -> String {
2717    let mut out = input.to_string();
2718    for (index, token) in tokens.iter().enumerate() {
2719        let replacement = match token {
2720            InlineToken::Link { label, target } => {
2721                format_markdown_link(label, target, url_scope, MarkdownLinkStyle::default())
2722            }
2723            InlineToken::Code(value) => format!("`BT383838`Fddd{}`f`b", m_escape(value)),
2724        };
2725        out = out.replace(&markdown_token_placeholder(index), &replacement);
2726    }
2727    out
2728}
2729
2730fn format_markdown_link(
2731    label: &str,
2732    target: &str,
2733    url_scope: Option<&str>,
2734    style: MarkdownLinkStyle<'_>,
2735) -> String {
2736    let mut link = String::new();
2737    if style.underline {
2738        link.push_str("`_");
2739    }
2740    if style.bold {
2741        link.push_str("`!");
2742    }
2743    link.push_str("`[");
2744    link.push_str(sanitize_label(label).trim());
2745    link.push('`');
2746    link.push_str(&markdown_link_target(target, url_scope));
2747    link.push(']');
2748    if style.bold {
2749        link.push_str("`!");
2750    }
2751    if style.underline {
2752        link.push_str("`_");
2753    }
2754
2755    match style.color {
2756        Some(color) if color.len() == 3 => format!("`F{color}{link}`f"),
2757        Some(color) if color.len() == 6 => format!("`FT{color}{link}`f"),
2758        _ => link,
2759    }
2760}
2761
2762fn markdown_link_target(value: &str, url_scope: Option<&str>) -> String {
2763    if let Some(scope) = url_scope {
2764        if !value.contains(":/") {
2765            let (path, anchor) = value
2766                .split_once('#')
2767                .map(|(path, anchor)| (path, Some(anchor)))
2768                .unwrap_or((value, None));
2769            let mut out = format!("{scope}{}", sanitize_field(path));
2770            if let Some(anchor) = anchor.filter(|anchor| !anchor.is_empty()) {
2771                out.push_str("|anchor=");
2772                out.push_str(&sanitize_field(anchor));
2773            }
2774            return out;
2775        }
2776    }
2777    sanitize_markdown_link_target(value)
2778}
2779
2780fn sanitize_markdown_link_target(value: &str) -> String {
2781    value
2782        .chars()
2783        .filter(|ch| *ch != '[' && *ch != ']' && *ch != '`')
2784        .collect()
2785}
2786
2787fn apply_markdown_style(input: &str, marker: &str, open: &str, close: &str) -> String {
2788    let mut out = String::new();
2789    let mut rest = input;
2790
2791    while let Some(start) = rest.find(marker) {
2792        let content_start = start + marker.len();
2793        let Some(end) = rest[content_start..].find(marker) else {
2794            out.push_str(rest);
2795            return out;
2796        };
2797        let content_end = content_start + end;
2798        out.push_str(&rest[..start]);
2799        out.push_str(open);
2800        out.push_str(&rest[content_start..content_end]);
2801        out.push_str(close);
2802        rest = &rest[content_end + marker.len()..];
2803    }
2804    out.push_str(rest);
2805    out
2806}
2807
2808#[derive(Debug)]
2809struct Refs {
2810    heads: Vec<RefInfo>,
2811    tags: Vec<RefInfo>,
2812}
2813
2814#[derive(Debug)]
2815struct RefInfo {
2816    sha: String,
2817    name: String,
2818}
2819
2820fn git_refs(repo: &Path) -> Result<Refs> {
2821    let output = run_git(
2822        Command::new("git")
2823            .arg("--git-dir")
2824            .arg(repo)
2825            .args(["for-each-ref", "--format=%(objectname) %(refname)"]),
2826    )?;
2827    let mut refs = Refs {
2828        heads: Vec::new(),
2829        tags: Vec::new(),
2830    };
2831    for line in output.lines() {
2832        let Some((sha, name)) = line.split_once(' ') else {
2833            continue;
2834        };
2835        if let Some(short) = name.strip_prefix("refs/heads/") {
2836            refs.heads.push(RefInfo {
2837                sha: sha.to_string(),
2838                name: short.to_string(),
2839            });
2840        } else if let Some(short) = name.strip_prefix("refs/tags/") {
2841            refs.tags.push(RefInfo {
2842                sha: sha.to_string(),
2843                name: short.to_string(),
2844            });
2845        }
2846    }
2847    Ok(refs)
2848}
2849
2850fn resolve_ref(repo: &Path, reference: &str) -> Result<String> {
2851    validate_refish(reference)?;
2852    let output = run_git(
2853        Command::new("git")
2854            .arg("--git-dir")
2855            .arg(repo)
2856            .arg("rev-parse")
2857            .arg("--verify")
2858            .arg(format!("{reference}^{{commit}}")),
2859    )?;
2860    Ok(output.trim().to_string())
2861}
2862
2863#[derive(Debug)]
2864struct TreeEntry {
2865    kind: String,
2866    name: String,
2867    size: Option<u64>,
2868}
2869
2870impl TreeEntry {
2871    fn size_label(&self) -> String {
2872        self.size
2873            .map(|size| format!(" ({size} bytes)"))
2874            .unwrap_or_default()
2875    }
2876}
2877
2878fn tree_entries(repo: &Path, reference: &str, path: &str) -> Result<Vec<TreeEntry>> {
2879    validate_git_path(path)?;
2880    let spec = if path.is_empty() {
2881        reference.to_string()
2882    } else {
2883        format!("{reference}:{path}")
2884    };
2885    let output = run_git(
2886        Command::new("git")
2887            .arg("--git-dir")
2888            .arg(repo)
2889            .arg("ls-tree")
2890            .arg("-l")
2891            .arg(spec),
2892    )?;
2893    let mut entries = Vec::new();
2894    for line in output.lines() {
2895        let Some((meta, name)) = line.split_once('\t') else {
2896            continue;
2897        };
2898        let parts: Vec<&str> = meta.split_whitespace().collect();
2899        if parts.len() < 4 {
2900            continue;
2901        }
2902        let size = parts[3].parse::<u64>().ok();
2903        entries.push(TreeEntry {
2904            kind: parts[1].to_string(),
2905            name: name.to_string(),
2906            size,
2907        });
2908    }
2909    entries.sort_by_key(|entry| (entry.kind != "tree", entry.name.to_ascii_lowercase()));
2910    Ok(entries)
2911}
2912
2913fn git_object_kind(repo: &Path, reference: &str, path: &str) -> Result<String> {
2914    validate_git_path(path)?;
2915    let spec = if path.is_empty() {
2916        reference.to_string()
2917    } else {
2918        format!("{reference}:{path}")
2919    };
2920    let output = run_git(
2921        Command::new("git")
2922            .arg("--git-dir")
2923            .arg(repo)
2924            .arg("cat-file")
2925            .arg("-t")
2926            .arg(spec),
2927    )?;
2928    Ok(output.trim().to_string())
2929}
2930
2931struct BlobInfo {
2932    content: String,
2933    size: u64,
2934    binary: bool,
2935    displayable: bool,
2936}
2937
2938fn blob_info(repo: &Path, reference: &str, path: &str) -> Result<BlobInfo> {
2939    validate_git_path(path)?;
2940    let spec = format!("{reference}:{path}");
2941    let size = run_git(
2942        Command::new("git")
2943            .arg("--git-dir")
2944            .arg(repo)
2945            .arg("cat-file")
2946            .arg("-s")
2947            .arg(&spec),
2948    )?
2949    .trim()
2950    .parse::<u64>()
2951    .unwrap_or(0);
2952    if size > 256 * 1024 {
2953        return Ok(BlobInfo {
2954            content: format!("File is too large to display ({size} bytes)."),
2955            size,
2956            binary: false,
2957            displayable: false,
2958        });
2959    }
2960    let output = run_git_output(
2961        Command::new("git")
2962            .arg("--git-dir")
2963            .arg(repo)
2964            .arg("show")
2965            .arg(spec),
2966        GIT_COMMAND_TIMEOUT,
2967    )?;
2968    if !output.status.success() {
2969        return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
2970    }
2971    let binary = output.stdout.contains(&0);
2972    Ok(BlobInfo {
2973        content: if binary {
2974            "Binary file is not displayed.".to_string()
2975        } else {
2976            String::from_utf8_lossy(&output.stdout).into_owned()
2977        },
2978        size,
2979        binary,
2980        displayable: !binary,
2981    })
2982}
2983
2984#[derive(Debug)]
2985struct CommitSummary {
2986    hash: String,
2987    subject: String,
2988    author: String,
2989    timestamp: i64,
2990}
2991
2992fn commits(repo: &Path, reference: &str, limit: usize) -> Result<Vec<CommitSummary>> {
2993    let format = "%H%x1f%s%x1f%an%x1f%at";
2994    let output = run_git(
2995        Command::new("git")
2996            .arg("--git-dir")
2997            .arg(repo)
2998            .arg("log")
2999            .arg(format!("--format={format}"))
3000            .arg("-n")
3001            .arg(limit.to_string())
3002            .arg(reference),
3003    )?;
3004    let mut commits = Vec::new();
3005    for line in output.lines() {
3006        let parts: Vec<&str> = line.split('\x1f').collect();
3007        if parts.len() == 4 {
3008            commits.push(CommitSummary {
3009                hash: parts[0].to_string(),
3010                subject: parts[1].to_string(),
3011                author: parts[2].to_string(),
3012                timestamp: parts[3].parse().unwrap_or_default(),
3013            });
3014        }
3015    }
3016    Ok(commits)
3017}
3018
3019#[derive(Debug)]
3020struct CommitInfo {
3021    hash: String,
3022    subject: String,
3023    body: String,
3024    author: String,
3025    email: String,
3026    timestamp: i64,
3027    files: Vec<CommitFile>,
3028}
3029
3030#[derive(Debug)]
3031struct CommitFile {
3032    status: String,
3033    path: String,
3034}
3035
3036fn commit_info(repo: &Path, hash: &str) -> Result<CommitInfo> {
3037    validate_refish(hash)?;
3038    let output = run_git(
3039        Command::new("git")
3040            .arg("--git-dir")
3041            .arg(repo)
3042            .arg("show")
3043            .arg("--no-patch")
3044            .arg("--format=%H%x1f%s%x1f%an%x1f%ae%x1f%at%x1f%B")
3045            .arg(hash),
3046    )?;
3047    let parts: Vec<&str> = output.splitn(6, '\x1f').collect();
3048    if parts.len() != 6 {
3049        return Err(Error::msg("invalid commit output"));
3050    }
3051    let files_output = run_git(
3052        Command::new("git")
3053            .arg("--git-dir")
3054            .arg(repo)
3055            .arg("diff-tree")
3056            .arg("--root")
3057            .arg("--no-commit-id")
3058            .arg("--name-status")
3059            .arg("-r")
3060            .arg(hash),
3061    )?;
3062    let mut files = Vec::new();
3063    for line in files_output.lines() {
3064        let Some((status, path)) = line.split_once('\t') else {
3065            continue;
3066        };
3067        files.push(CommitFile {
3068            status: status.to_string(),
3069            path: path.to_string(),
3070        });
3071    }
3072    Ok(CommitInfo {
3073        hash: parts[0].trim().to_string(),
3074        subject: parts[1].trim().to_string(),
3075        author: parts[2].trim().to_string(),
3076        email: parts[3].trim().to_string(),
3077        timestamp: parts[4].trim().parse().unwrap_or_default(),
3078        body: parts[5].trim().to_string(),
3079        files,
3080    })
3081}
3082
3083fn raw_commit_object(repo: &Path, hash: &str) -> Result<Vec<u8>> {
3084    validate_refish(hash)?;
3085    let output = run_git_output(
3086        Command::new("git")
3087            .arg("--git-dir")
3088            .arg(repo)
3089            .arg("cat-file")
3090            .arg("-p")
3091            .arg(hash),
3092        GIT_COMMAND_TIMEOUT,
3093    )?;
3094    if !output.status.success() {
3095        return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
3096    }
3097    Ok(output.stdout)
3098}
3099
3100fn run_git(cmd: &mut Command) -> Result<String> {
3101    let output = run_git_output(cmd, GIT_COMMAND_TIMEOUT)?;
3102    if !output.status.success() {
3103        return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
3104    }
3105    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
3106}
3107
3108fn run_git_output(cmd: &mut Command, timeout: Duration) -> Result<Output> {
3109    let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
3110    let deadline = Instant::now() + timeout;
3111
3112    loop {
3113        if child.try_wait()?.is_some() {
3114            return child.wait_with_output().map_err(Error::from);
3115        }
3116
3117        if Instant::now() >= deadline {
3118            let _ = child.kill();
3119            let _ = child.wait();
3120            log::warn!("Git command execution timed out");
3121            return Err(Error::msg("Git command execution timed out"));
3122        }
3123
3124        let sleep_for = deadline
3125            .saturating_duration_since(Instant::now())
3126            .min(Duration::from_millis(10));
3127        if !sleep_for.is_zero() {
3128            thread::sleep(sleep_for);
3129        }
3130    }
3131}
3132
3133#[cfg(test)]
3134mod tests {
3135    use super::*;
3136    use crate::acl::Access;
3137    use crate::config::ServerConfig;
3138
3139    #[test]
3140    fn blob_path_normalization_removes_only_the_explicit_relative_prefix() {
3141        assert_eq!(normalize_blob_path("./docs/guide.md"), "docs/guide.md");
3142        assert_eq!(normalize_blob_path("docs/./guide.md"), "docs/guide.md");
3143        assert_eq!(normalize_blob_path(".hidden/config"), ".hidden/config");
3144        assert_eq!(normalize_blob_path(".../guide.md"), ".../guide.md");
3145        assert_eq!(normalize_blob_path("/absolute.md"), "/absolute.md");
3146        assert!(validate_git_path(&normalize_blob_path("/absolute.md")).is_err());
3147    }
3148    use crate::logging;
3149    use rns_core::msgpack::{self, Value};
3150    use rns_crypto::OsRng;
3151    use std::fs;
3152    use std::process::Command;
3153
3154    #[test]
3155    fn nomadnet_destination_uses_upstream_name() {
3156        let identity = Identity::new(&mut OsRng);
3157        let destination = destination_for_identity(&identity);
3158        let expected =
3159            Destination::single_in("nomadnetwork", &["node"], IdentityHash(*identity.hash()));
3160        assert_eq!(destination.hash, expected.hash);
3161    }
3162
3163    #[test]
3164    fn chart_renders_positive_values_on_bottom_row() {
3165        let labels = ["start".to_string(), "end".to_string()];
3166        let chart = render_chart(&[1, 100], &labels, "57c", None, 1.3, 10);
3167        let bottom_row = chart.lines().nth(10).unwrap();
3168
3169        assert!(bottom_row.starts_with("│`FT"));
3170        assert!(bottom_row.contains('▄'));
3171    }
3172
3173    #[test]
3174    fn git_command_timeout_kills_slow_process() {
3175        let start = Instant::now();
3176
3177        let err =
3178            run_git_output(Command::new("sleep").arg("5"), Duration::from_millis(30)).unwrap_err();
3179
3180        assert!(start.elapsed() < Duration::from_secs(1));
3181        assert!(err.to_string().contains("timed out"));
3182    }
3183
3184    #[test]
3185    fn commit_message_formatter_escapes_leading_dash_lines() {
3186        let formatted = format_commit_message("subject\n- rendered literally\npath\\with`tick`");
3187
3188        assert_eq!(
3189            formatted,
3190            "subject\n\\- rendered literally\npath\\\\with\\`tick\\`"
3191        );
3192    }
3193
3194    #[test]
3195    fn front_page_lists_only_readable_groups() {
3196        let tmp = tempfile::tempdir().unwrap();
3197        let mut config = cfg(tmp.path());
3198        config.allow_read = vec!["none".into()];
3199        create_repo(
3200            config.repositories_dir.join("public/alpha"),
3201            "README.md",
3202            "alpha readme\n",
3203        );
3204        create_repo(
3205            config.repositories_dir.join("private/secret"),
3206            "README.md",
3207            "secret\n",
3208        );
3209        fs::write(
3210            config.repositories_dir.join("public/group.allowed"),
3211            "read = all\n",
3212        )
3213        .unwrap();
3214        fs::write(
3215            config.repositories_dir.join("private/group.allowed"),
3216            "read = none\n",
3217        )
3218        .unwrap();
3219        let access = access(&config);
3220
3221        let page = render_page(PATH_INDEX, &config, &access, &page_request(&[]), None).unwrap();
3222        assert!(page.contains("public"));
3223        assert!(page.contains("alpha"));
3224        assert!(!page.contains("private"));
3225        assert!(!page.contains("secret"));
3226    }
3227
3228    #[test]
3229    fn group_and_repo_pages_use_repository_metadata() {
3230        let tmp = tempfile::tempdir().unwrap();
3231        let config = cfg(tmp.path());
3232        let repo = create_repo(
3233            config.repositories_dir.join("public/alpha"),
3234            "README.md",
3235            "# Alpha\n",
3236        );
3237        fs::write(repo.join("description"), "Alpha repository\n").unwrap();
3238        let access = access(&config);
3239
3240        let group = render_page(
3241            PATH_GROUP,
3242            &config,
3243            &access,
3244            &page_request(&[("var_g", "public")]),
3245            None,
3246        )
3247        .unwrap();
3248        assert!(group.contains("Alpha repository"));
3249        assert!(group.contains("alpha"));
3250
3251        let repo = render_page(
3252            PATH_REPO,
3253            &config,
3254            &access,
3255            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3256            None,
3257        )
3258        .unwrap();
3259        assert!(repo.contains("rns://"));
3260        assert!(repo.contains("Alpha repository"));
3261        assert!(repo.contains(">Alpha"));
3262    }
3263
3264    #[test]
3265    fn repo_page_renders_fork_and_mirror_sources() {
3266        let tmp = tempfile::tempdir().unwrap();
3267        let config = cfg(tmp.path());
3268        let fork = create_repo(
3269            config.repositories_dir.join("public/forked"),
3270            "README.md",
3271            "# Forked\n",
3272        );
3273        let mirror = create_repo(
3274            config.repositories_dir.join("public/mirrored"),
3275            "README.md",
3276            "# Mirrored\n",
3277        );
3278        run_git(Command::new("git").arg("--git-dir").arg(&fork).args([
3279            "config",
3280            "repository.rngit.type",
3281            "fork",
3282        ]));
3283        run_git(Command::new("git").arg("--git-dir").arg(&fork).args([
3284            "config",
3285            "repository.rngit.upstream.source",
3286            "rns://00112233445566778899aabbccddeeff/source/repo",
3287        ]));
3288        run_git(Command::new("git").arg("--git-dir").arg(&fork).args([
3289            "config",
3290            "repository.rngit.upstream.sync",
3291            "1",
3292        ]));
3293        run_git(Command::new("git").arg("--git-dir").arg(&mirror).args([
3294            "config",
3295            "repository.rngit.type",
3296            "mirror",
3297        ]));
3298        run_git(Command::new("git").arg("--git-dir").arg(&mirror).args([
3299            "config",
3300            "repository.rngit.upstream.source",
3301            "https://example.invalid/upstream.git",
3302        ]));
3303        run_git(Command::new("git").arg("--git-dir").arg(&mirror).args([
3304            "config",
3305            "repository.rngit.upstream.sync",
3306            "1",
3307        ]));
3308        let access = access(&config);
3309
3310        let fork_page = render_page(
3311            PATH_REPO,
3312            &config,
3313            &access,
3314            &page_request(&[("var_g", "public"), ("var_r", "forked")]),
3315            None,
3316        )
3317        .unwrap();
3318        assert!(fork_page.contains("Forked from"));
3319        assert!(fork_page.contains("rns://00112233445566778899aabbccddeeff/source/repo"));
3320        assert!(fork_page.contains("synced"));
3321        assert!(fork_page.contains("ago"));
3322
3323        let mirror_page = render_page(
3324            PATH_REPO,
3325            &config,
3326            &access,
3327            &page_request(&[("var_g", "public"), ("var_r", "mirrored")]),
3328            None,
3329        )
3330        .unwrap();
3331        assert!(mirror_page.contains("Mirrored from"));
3332        assert!(mirror_page.contains("https://example.invalid/upstream.git"));
3333        assert!(mirror_page.contains("synced"));
3334        assert!(mirror_page.contains("ago"));
3335    }
3336
3337    #[test]
3338    fn tree_blob_commits_commit_and_refs_pages_are_git_backed() {
3339        let tmp = tempfile::tempdir().unwrap();
3340        let config = cfg(tmp.path());
3341        let repo = create_repo(
3342            config.repositories_dir.join("public/alpha"),
3343            "src/lib.rs",
3344            "pub fn answer() -> u8 { 42 }\n",
3345        );
3346        run_git(Command::new("git").arg("--git-dir").arg(&repo).args([
3347            "tag",
3348            "v1",
3349            "refs/heads/main",
3350        ]));
3351        let access = access(&config);
3352
3353        let tree = render_page(
3354            PATH_TREE,
3355            &config,
3356            &access,
3357            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3358            None,
3359        )
3360        .unwrap();
3361        assert!(tree.contains("src/"));
3362
3363        let blob = render_page(
3364            PATH_BLOB,
3365            &config,
3366            &access,
3367            &page_request(&[
3368                ("var_g", "public"),
3369                ("var_r", "alpha"),
3370                ("var_path", "src/lib.rs"),
3371            ]),
3372            None,
3373        )
3374        .unwrap();
3375        assert!(blob.contains("answer"));
3376        assert!(blob.contains("pub fn answer()"));
3377
3378        let commits = render_page(
3379            PATH_COMMITS,
3380            &config,
3381            &access,
3382            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3383            None,
3384        )
3385        .unwrap();
3386        assert!(commits.contains("initial"));
3387
3388        let commit_hash = run_git(
3389            Command::new("git")
3390                .arg("--git-dir")
3391                .arg(&repo)
3392                .args(["rev-parse", "refs/heads/main"]),
3393        );
3394        run_git(Command::new("git").arg("--git-dir").arg(&repo).args([
3395            "update-ref",
3396            "refs/heads/feature",
3397            commit_hash.trim(),
3398        ]));
3399        let branch_commits = render_page(
3400            PATH_COMMITS,
3401            &config,
3402            &access,
3403            &page_request(&[
3404                ("var_g", "public"),
3405                ("var_r", "alpha"),
3406                ("var_ref", "feature"),
3407            ]),
3408            None,
3409        )
3410        .unwrap();
3411        assert!(branch_commits.contains("|ref=feature|h="));
3412        let commit = render_page(
3413            PATH_COMMIT,
3414            &config,
3415            &access,
3416            &page_request(&[
3417                ("var_g", "public"),
3418                ("var_r", "alpha"),
3419                ("var_ref", "feature"),
3420                ("var_h", commit_hash.trim()),
3421            ]),
3422            None,
3423        )
3424        .unwrap();
3425        assert!(commit.contains("initial"));
3426        assert!(commit.contains("src/lib.rs"));
3427        assert!(commit.contains("/page/commits.mu`g=public|r=alpha|ref=feature"));
3428        assert!(!commit.contains("Signature:"));
3429
3430        let refs = render_page(
3431            PATH_REFS,
3432            &config,
3433            &access,
3434            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3435            None,
3436        )
3437        .unwrap();
3438        assert!(refs.contains("main"));
3439        assert!(refs.contains("v1"));
3440    }
3441
3442    #[test]
3443    fn commit_page_shows_valid_reticulum_signature_status() {
3444        let tmp = tempfile::tempdir().unwrap();
3445        let config = cfg(tmp.path());
3446        let repo = create_repo(
3447            config.repositories_dir.join("public/alpha"),
3448            "src/lib.rs",
3449            "pub fn answer() -> u8 { 42 }\n",
3450        );
3451        let access = access(&config);
3452        let tree = run_git(
3453            Command::new("git")
3454                .arg("--git-dir")
3455                .arg(&repo)
3456                .args(["rev-parse", "refs/heads/main^{tree}"]),
3457        );
3458        let identity = Identity::new(&mut OsRng);
3459        let author = crate::util::hex(identity.hash());
3460        let signed_content = format!(
3461            "tree {}\nauthor RNS Page Test <{}> 0 +0000\ncommitter RNS Page Test <{}> 0 +0000\n\nsigned commit\n",
3462            tree.trim(),
3463            author,
3464            author
3465        );
3466        let armored =
3467            crate::commitsigs::sign_message(&identity, signed_content.as_bytes()).unwrap();
3468        let (headers, rest) = signed_content.split_once("\n\n").unwrap();
3469        let mut raw_commit = format!("{headers}\n");
3470        let mut sig_lines = armored.lines();
3471        raw_commit.push_str("gpgsig ");
3472        raw_commit.push_str(sig_lines.next().unwrap());
3473        raw_commit.push('\n');
3474        for line in sig_lines {
3475            raw_commit.push(' ');
3476            raw_commit.push_str(line);
3477            raw_commit.push('\n');
3478        }
3479        raw_commit.push('\n');
3480        raw_commit.push_str(rest);
3481        let commit_file = tmp.path().join("signed-commit");
3482        fs::write(&commit_file, raw_commit).unwrap();
3483        let commit_hash = run_git(
3484            Command::new("git")
3485                .arg("--git-dir")
3486                .arg(&repo)
3487                .args(["hash-object", "-t", "commit", "-w"])
3488                .arg(&commit_file),
3489        );
3490        run_git(Command::new("git").arg("--git-dir").arg(&repo).args([
3491            "update-ref",
3492            "refs/heads/main",
3493            commit_hash.trim(),
3494        ]));
3495
3496        let page = render_page(
3497            PATH_COMMIT,
3498            &config,
3499            &access,
3500            &page_request(&[
3501                ("var_g", "public"),
3502                ("var_r", "alpha"),
3503                ("var_h", commit_hash.trim()),
3504            ]),
3505            None,
3506        )
3507        .unwrap();
3508
3509        assert!(page.contains("signed commit"));
3510        assert!(page.contains("Signature: Valid, signed by author"));
3511    }
3512
3513    #[test]
3514    fn blob_page_renders_tree_when_path_is_directory() {
3515        let tmp = tempfile::tempdir().unwrap();
3516        let config = cfg(tmp.path());
3517        create_repo(
3518            config.repositories_dir.join("public/alpha"),
3519            "src/lib.rs",
3520            "pub fn answer() -> u8 { 42 }\n",
3521        );
3522        let access = access(&config);
3523
3524        let page = render_page(
3525            PATH_BLOB,
3526            &config,
3527            &access,
3528            &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_path", "src")]),
3529            None,
3530        )
3531        .unwrap();
3532
3533        assert!(page.contains("files / src"));
3534        assert!(page.contains("lib.rs"));
3535        assert!(page.contains(PATH_BLOB));
3536        assert!(!page.contains("Displaying Raw"));
3537    }
3538
3539    #[test]
3540    fn page_requests_reject_invalid_repository_names() {
3541        let tmp = tempfile::tempdir().unwrap();
3542        let config = cfg(tmp.path());
3543        let access = access(&config);
3544        let err = render_page(
3545            PATH_REPO,
3546            &config,
3547            &access,
3548            &page_request(&[("var_g", ".."), ("var_r", "repo")]),
3549            None,
3550        )
3551        .unwrap_err();
3552        assert!(err.to_string().contains("invalid repository name"));
3553    }
3554
3555    #[test]
3556    fn page_ref_resolution_rejects_invalid_refs_before_git_lookup() {
3557        let tmp = tempfile::tempdir().unwrap();
3558        let config = cfg(tmp.path());
3559        create_repo(
3560            config.repositories_dir.join("public/alpha"),
3561            "README.md",
3562            "# Alpha\n",
3563        );
3564        let access = access(&config);
3565
3566        for (path, vars) in [
3567            (
3568                PATH_TREE,
3569                vec![
3570                    ("var_g", "public"),
3571                    ("var_r", "alpha"),
3572                    ("var_ref", "--upload-pack=/tmp/x"),
3573                ],
3574            ),
3575            (
3576                PATH_BLOB,
3577                vec![
3578                    ("var_g", "public"),
3579                    ("var_r", "alpha"),
3580                    ("var_path", "README.md"),
3581                    ("var_ref", "refs/heads/main:README.md"),
3582                ],
3583            ),
3584            (
3585                PATH_COMMITS,
3586                vec![
3587                    ("var_g", "public"),
3588                    ("var_r", "alpha"),
3589                    ("var_ref", "refs/heads/main..refs/heads/other"),
3590                ],
3591            ),
3592        ] {
3593            let err = render_page(path, &config, &access, &page_request(&vars), None).unwrap_err();
3594            assert_eq!(err.to_string(), "invalid git ref");
3595        }
3596    }
3597
3598    #[test]
3599    fn pages_use_base_template_breadcrumbs_and_micron_links() {
3600        let tmp = tempfile::tempdir().unwrap();
3601        let config = cfg(tmp.path());
3602        create_repo(
3603            config.repositories_dir.join("public/alpha"),
3604            "README.md",
3605            "# Alpha\n",
3606        );
3607        let access = access(&config);
3608
3609        let repo = render_page(
3610            PATH_REPO,
3611            &config,
3612            &access,
3613            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3614            None,
3615        )
3616        .unwrap();
3617
3618        assert!(repo.starts_with("#!c=0\n>Test Git Node\n\n>>\n"));
3619        assert!(repo.contains("`!`[Node`:/page/index.mu]`!"));
3620        assert!(repo.contains("`!`[public`:/page/group.mu`g=public]`!"));
3621        assert!(repo.contains("`[Files`:/page/tree.mu`g=public|r=alpha|ref=HEAD]"));
3622        assert!(repo.contains("`[Commits"));
3623        assert!(repo.contains("`[Branches (1)`:/page/refs.mu`g=public|r=alpha|type=heads]"));
3624        assert!(repo.contains("`[Tags (0)`:/page/refs.mu`g=public|r=alpha|type=tags]"));
3625        assert!(repo.contains("<\n-\n`a`F666`[Served by rngit`:/page/index.mu]"));
3626    }
3627
3628    #[test]
3629    fn custom_templates_wrap_page_content_and_replace_variables() {
3630        let tmp = tempfile::tempdir().unwrap();
3631        let config = cfg(tmp.path());
3632        fs::create_dir_all(&config.templates_dir).unwrap();
3633        fs::write(
3634            config.templates_dir.join("base.mu"),
3635            "BASE {NODE_NAME} {VERSION} {GEN_TIME}\n{PAGE_CONTENT}\n{NAVIGATION}   \n",
3636        )
3637        .unwrap();
3638        fs::write(
3639            config.templates_dir.join("repo.mu"),
3640            "REPO-BEGIN\n{PAGE_CONTENT}\nREPO-END   \n",
3641        )
3642        .unwrap();
3643        create_repo(
3644            config.repositories_dir.join("public/alpha"),
3645            "README.md",
3646            "# Alpha\n",
3647        );
3648        let access = access(&config);
3649
3650        let repo = render_page(
3651            PATH_REPO,
3652            &config,
3653            &access,
3654            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3655            None,
3656        )
3657        .unwrap();
3658
3659        assert!(repo.starts_with("BASE Test Git Node "));
3660        assert!(repo.contains("\nREPO-BEGIN\n"));
3661        assert!(repo.contains(">Alpha"));
3662        assert!(repo.contains("\nREPO-END\n"));
3663        assert!(repo.contains(env!("CARGO_PKG_VERSION")));
3664        assert!(repo.contains("0ms"));
3665        assert!(!repo.ends_with("   \n"));
3666    }
3667
3668    #[test]
3669    fn blocked_unidentified_requests_use_no_ident_page_for_every_page_path() {
3670        let tmp = tempfile::tempdir().unwrap();
3671        let mut config = cfg(tmp.path());
3672        config.blocked_identities.push(NULL_IDENTITY_HASH);
3673        let access = access(&config);
3674
3675        for path in PAGE_PATHS {
3676            let page = render_page(path, &config, &access, &page_request(&[]), None).unwrap();
3677            assert!(
3678                page.contains(
3679                    ">>No Identity\n\nThis page requires identification, and none was received."
3680                ),
3681                "{path} did not render the no-ident page: {page}"
3682            );
3683        }
3684    }
3685
3686    #[test]
3687    fn no_ident_page_requires_both_missing_identity_and_blocked_null_hash() {
3688        let tmp = tempfile::tempdir().unwrap();
3689        let mut config = cfg(tmp.path());
3690        let access = access(&config);
3691
3692        let allowed_unidentified =
3693            render_page(PATH_INDEX, &config, &access, &page_request(&[]), None).unwrap();
3694        assert!(!allowed_unidentified.contains(">>No Identity"));
3695
3696        config.blocked_identities.push(NULL_IDENTITY_HASH);
3697        let identified = [0x42; 16];
3698        let allowed_identified = render_page(
3699            PATH_INDEX,
3700            &config,
3701            &access,
3702            &page_request(&[]),
3703            Some(&identified),
3704        )
3705        .unwrap();
3706        assert!(!allowed_identified.contains(">>No Identity"));
3707    }
3708
3709    #[test]
3710    fn custom_no_ident_template_overrides_the_default() {
3711        let tmp = tempfile::tempdir().unwrap();
3712        let mut config = cfg(tmp.path());
3713        config.blocked_identities.push(NULL_IDENTITY_HASH);
3714        fs::create_dir_all(&config.templates_dir).unwrap();
3715        fs::write(
3716            config.templates_dir.join("no_ident.mu"),
3717            ">>Identify First\n\nCustom unidentified response for {NODE_NAME}.\n",
3718        )
3719        .unwrap();
3720        let access = access(&config);
3721
3722        let page = render_page(PATH_INDEX, &config, &access, &page_request(&[]), None).unwrap();
3723
3724        assert!(page.contains(">>Identify First"));
3725        assert!(page.contains("Custom unidentified response for Test Git Node."));
3726        assert!(!page.contains(">>No Identity"));
3727    }
3728
3729    #[test]
3730    fn template_substitution_does_not_rewrite_page_content_placeholders() {
3731        let tmp = tempfile::tempdir().unwrap();
3732        let config = cfg(tmp.path());
3733
3734        let out = apply_template(
3735            "Template {NODE_NAME}\n{PAGE_CONTENT}",
3736            "Literal {NODE_NAME}",
3737            &config,
3738        );
3739
3740        assert!(out.starts_with("Template Test Git Node\n"));
3741        assert!(out.ends_with("Literal {NODE_NAME}"));
3742    }
3743
3744    #[test]
3745    fn unicode_icon_config_changes_repository_navigation_labels() {
3746        let tmp = tempfile::tempdir().unwrap();
3747        let mut config = cfg(tmp.path());
3748        config.unicode_icons = true;
3749        create_repo(
3750            config.repositories_dir.join("public/alpha"),
3751            "README.md",
3752            "# Alpha\n",
3753        );
3754        let access = access(&config);
3755
3756        let repo = render_page(
3757            PATH_REPO,
3758            &config,
3759            &access,
3760            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3761            None,
3762        )
3763        .unwrap();
3764
3765        assert!(repo.contains("🗀 Files"));
3766        assert!(repo.contains("🖹 Commits"));
3767        assert!(repo.contains("⑃ Branches"));
3768        assert!(repo.contains("⌆ Tags"));
3769    }
3770
3771    #[test]
3772    fn repo_page_renders_and_persists_thanks_count() {
3773        let tmp = tempfile::tempdir().unwrap();
3774        let config = cfg(tmp.path());
3775        let repo_path = create_repo(
3776            config.repositories_dir.join("public/alpha"),
3777            "README.md",
3778            "# Alpha\n",
3779        );
3780        let access = access(&config);
3781
3782        let first = render_page(
3783            PATH_REPO,
3784            &config,
3785            &access,
3786            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3787            None,
3788        )
3789        .unwrap();
3790        assert!(first.contains("Thanks (0)"));
3791        assert!(!repo_path.with_extension("thanks").exists());
3792
3793        let thanked = render_page(
3794            PATH_REPO,
3795            &config,
3796            &access,
3797            &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_thanks", "y")]),
3798            None,
3799        )
3800        .unwrap();
3801        assert!(thanked.contains("Thanks (1)"));
3802        assert_eq!(
3803            fs::read_to_string(repo_path.with_extension("thanks")).unwrap(),
3804            "1\n"
3805        );
3806
3807        let incremented = render_page(
3808            PATH_REPO,
3809            &config,
3810            &access,
3811            &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_thanks", "y")]),
3812            None,
3813        )
3814        .unwrap();
3815        assert!(incremented.contains("Thanks (2)"));
3816
3817        let viewed = render_page(
3818            PATH_REPO,
3819            &config,
3820            &access,
3821            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3822            None,
3823        )
3824        .unwrap();
3825        assert!(viewed.contains("Thanks (2)"));
3826    }
3827
3828    #[test]
3829    fn repo_page_uses_unicode_heart_for_thanks_when_enabled() {
3830        let tmp = tempfile::tempdir().unwrap();
3831        let mut config = cfg(tmp.path());
3832        config.unicode_icons = true;
3833        create_repo(
3834            config.repositories_dir.join("public/alpha"),
3835            "README.md",
3836            "# Alpha\n",
3837        );
3838        let access = access(&config);
3839
3840        let page = render_page(
3841            PATH_REPO,
3842            &config,
3843            &access,
3844            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3845            None,
3846        )
3847        .unwrap();
3848
3849        assert!(page.contains("♥ Thanks (0)"));
3850        assert!(page.contains("thanks=y"));
3851    }
3852
3853    #[test]
3854    fn empty_page_request_data_is_deterministic() {
3855        let tmp = tempfile::tempdir().unwrap();
3856        let config = cfg(tmp.path());
3857        let access = access(&config);
3858
3859        let index = render_page(PATH_INDEX, &config, &access, &[], None).unwrap();
3860        assert!(index.contains(">Groups"));
3861
3862        let err = render_page(PATH_REPO, &config, &access, &[], None).unwrap_err();
3863        assert!(err.to_string().contains("missing page variable g"));
3864    }
3865
3866    #[test]
3867    fn tree_blob_and_commit_pages_escape_content_and_format_metadata() {
3868        let tmp = tempfile::tempdir().unwrap();
3869        let config = cfg(tmp.path());
3870        let repo = create_repo(
3871            config.repositories_dir.join("public/alpha"),
3872            "src/lib.rs",
3873            "pub fn `answer`() -> u8 { 42 }\n",
3874        );
3875        let access = access(&config);
3876
3877        let tree = render_page(
3878            PATH_TREE,
3879            &config,
3880            &access,
3881            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3882            None,
3883        )
3884        .unwrap();
3885        assert!(tree.contains("`[src/`:/page/tree.mu`g=public|r=alpha|ref=HEAD|path=src]"));
3886
3887        let blob = render_page(
3888            PATH_BLOB,
3889            &config,
3890            &access,
3891            &page_request(&[
3892                ("var_g", "public"),
3893                ("var_r", "alpha"),
3894                ("var_path", "src/lib.rs"),
3895            ]),
3896            None,
3897        )
3898        .unwrap();
3899        assert!(blob.contains("Text, "));
3900        assert!(!blob.contains("View raw"));
3901        assert!(blob.contains("answer"));
3902        assert!(blob.contains("\\`"));
3903
3904        let silly_blob = render_page(
3905            PATH_BLOB,
3906            &config,
3907            &access,
3908            &page_request(&[
3909                ("var_g", "public"),
3910                ("var_r", "alpha"),
3911                ("var_path", "./src/./lib.rs"),
3912            ]),
3913            None,
3914        )
3915        .unwrap();
3916        assert!(silly_blob.contains(">src/lib.rs `F666"));
3917        assert!(silly_blob.contains("answer"));
3918
3919        let commit_hash = run_git(
3920            Command::new("git")
3921                .arg("--git-dir")
3922                .arg(&repo)
3923                .args(["rev-parse", "refs/heads/main"]),
3924        );
3925        let commit = render_page(
3926            PATH_COMMIT,
3927            &config,
3928            &access,
3929            &page_request(&[
3930                ("var_g", "public"),
3931                ("var_r", "alpha"),
3932                ("var_h", commit_hash.trim()),
3933            ]),
3934            None,
3935        )
3936        .unwrap();
3937        assert!(commit.contains("Author: RNS Page Test"));
3938        assert!(commit.contains("Files changed"));
3939        assert!(commit.contains("A src/lib.rs"));
3940    }
3941
3942    #[test]
3943    fn markdown_converter_handles_links_code_lists_rules_and_tables() {
3944        let input = "# Title\n\
3945\n\
3946See [the docs](https://example.invalid/a_b) and `literal *code*`.\n\
3947\n\
3948- **bold** and *italic*\n\
3949---\n\
3950| Name | Link |\n\
3951| --- | --- |\n\
3952| Row | [go](rns://abc) |\n";
3953
3954        let out = markdown_to_micron(input);
3955        assert!(out.contains(">Title"));
3956        assert!(out.contains("`_`!`[the docs`https://example.invalid/a_b]`!`_"));
3957        assert!(out.contains("`BT383838`Fdddliteral *code*`f`b"));
3958        assert!(out.contains(" • `!bold`! and `*italic`*"));
3959        assert!(out.contains("\n-\n"));
3960        assert!(out.contains("│ Name"));
3961        assert!(out.contains("`_`!`[go`rns://abc]`!`_"));
3962    }
3963
3964    #[test]
3965    fn markdown_converter_handles_blockquotes_and_heading_inline_markup() {
3966        let out = markdown_to_micron(
3967            "# **Important**\n\
3968> **Quoted** text\n\
3969> with [link](rns://abc)\n\
3970\n\
3971after\n",
3972        );
3973        assert!(out.contains(">`!Important`!"));
3974        assert!(out.contains(" │ `!Quoted`! text with `_`!`[link`rns://abc]`!`_"));
3975        assert!(out.contains("\nafter\n"));
3976    }
3977
3978    #[test]
3979    fn markdown_link_conversion_is_isolated_from_later_substitutions() {
3980        let out = markdown_to_micron("[**bold** `code`](https://example.invalid?q=*x*)");
3981        assert!(out.contains("`_`!`[**bold** code`https://example.invalid?q=*x*]`!`_"));
3982        assert!(!out.contains("`!`!"));
3983        assert!(!out.contains("`*x`*"));
3984    }
3985
3986    #[test]
3987    fn markdown_link_style_can_disable_wrappers_and_apply_color() {
3988        let plain = format_markdown_link(
3989            "label",
3990            "rns://abc",
3991            None,
3992            MarkdownLinkStyle {
3993                bold: false,
3994                underline: false,
3995                color: None,
3996            },
3997        );
3998        assert_eq!(plain, "`[label`rns://abc]");
3999
4000        let short_color = format_markdown_link(
4001            "label",
4002            "rns://abc",
4003            None,
4004            MarkdownLinkStyle {
4005                bold: true,
4006                underline: false,
4007                color: Some("abc"),
4008            },
4009        );
4010        assert_eq!(short_color, "`Fabc`!`[label`rns://abc]`!`f");
4011
4012        let true_color = format_markdown_link(
4013            "label",
4014            "rns://abc",
4015            None,
4016            MarkdownLinkStyle {
4017                bold: false,
4018                underline: true,
4019                color: Some("aabbcc"),
4020            },
4021        );
4022        assert_eq!(true_color, "`FTaabbcc`_`[label`rns://abc]`_`f");
4023    }
4024
4025    #[test]
4026    fn markdown_inline_code_that_looks_like_link_does_not_hide_real_links() {
4027        let out = markdown_to_micron(
4028            "`[literal](https://example.invalid/no)` and [real](https://example.invalid/yes)",
4029        );
4030
4031        assert!(out.contains("`BT383838`Fddd[literal](https://example.invalid/no)`f`b"));
4032        assert!(out.contains("`_`!`[real`https://example.invalid/yes]`!`_"));
4033        assert!(!out.contains("`_`!`[literal`https://example.invalid/no]`!`_"));
4034    }
4035
4036    #[test]
4037    fn markdown_tables_handle_empty_cells_escaped_pipes_alignment_and_visible_width() {
4038        let input = "| Name | Status | Notes |\n\
4039| :--- | ---: | :---: |\n\
4040| `code` | **ok** | a\\|b |\n\
4041| empty | | [go](rns://abc) |\n";
4042
4043        let out = markdown_to_micron(input);
4044        assert!(out.contains("┌"));
4045        assert!(out.contains("├"));
4046        assert!(out.contains("└"));
4047        assert!(out.contains("│ Name"));
4048        assert!(out.contains("`BT383838`Fdddcode`f`b"));
4049        assert!(out.contains("`!ok`!"));
4050        assert!(out.contains("a|b"));
4051        assert!(out.contains("empty"));
4052        assert!(out.contains("`_`!`[go`rns://abc]`!`_"));
4053        assert!(!out.contains("a\\|b"));
4054    }
4055
4056    #[test]
4057    fn markdown_table_width_ignores_generated_micron_tags() {
4058        let input = "| A | B |\n\
4059| --- | --- |\n\
4060| [x](rns://abc) | `y` |\n";
4061
4062        let out = markdown_to_micron(input);
4063        assert!(out.contains("│ `_`!`[x`rns://abc]`!`_ "));
4064        assert!(out.contains("│ `BT383838`Fdddy`f`b "));
4065        assert!(out.contains("┌─────┬─────┐"));
4066    }
4067
4068    #[test]
4069    fn markdown_escaping_does_not_create_or_corrupt_micron_tags() {
4070        let out =
4071            markdown_to_micron("literal `!not bold`! and [bad`label](rns://abc`def) plus `raw`");
4072        assert!(out.contains("literal \\`!not bold\\`!"));
4073        assert!(out.contains("`_`!`[badlabel`rns://abcdef]`!`_"));
4074        assert!(out.contains("`BT383838`Fdddraw`f`b"));
4075        assert!(!out.contains("`!not bold`!"));
4076    }
4077
4078    #[test]
4079    fn markdown_escapes_backslashes_and_expands_tabs_before_inline_formatting() {
4080        let out = markdown_to_micron("plain\\path\t**bold\\path** and `code\\path`\n");
4081
4082        assert!(out.contains("plain\\\\path   `!bold\\\\path`!"));
4083        assert!(out.contains("`BT383838`Fdddcode\\\\path`f`b"));
4084    }
4085
4086    #[test]
4087    fn markdown_plain_lines_escape_micron_line_start_controls() {
4088        let out = markdown_to_micron("-not a list\n<not alignment\n- list item\n---\n");
4089        assert!(out.contains("\\-not a list\n"));
4090        assert!(out.contains("\\<not alignment\n"));
4091        assert!(out.contains(" • list item\n"));
4092        assert!(out.contains("-\n"));
4093    }
4094
4095    #[test]
4096    fn markdown_converter_keeps_code_blocks_and_unmatched_markers_literal() {
4097        let out = markdown_to_micron(
4098            "```rust\n\
4099**not bold** and `not inline`\n\
4100```\n\
4101Unmatched * marker\n\
41021. numbered\n",
4103        );
4104        assert!(out.contains("not bold"));
4105        assert!(out.contains("\\`not inline\\`"));
4106        assert!(out.contains("`=\n**not bold** and \\`not inline\\`\n`=\n"));
4107        assert!(!out.contains("`!not bold`!"));
4108        assert!(out.contains("Unmatched * marker"));
4109        assert!(out.contains("1. numbered"));
4110    }
4111
4112    #[test]
4113    fn rawmu_fenced_blocks_pass_through_without_escaping() {
4114        let markdown = markdown_to_micron(
4115            "```rawmu
4116`!raw micron`!
4117>Heading
4118```
4119",
4120        );
4121
4122        assert!(markdown.contains(
4123            "`!raw micron`!
4124>Heading
4125"
4126        ));
4127        assert!(!markdown.contains(r"\`!raw micron"));
4128        assert!(!markdown.contains(
4129            "`=
4130"
4131        ));
4132    }
4133
4134    #[test]
4135    fn unknown_extension_and_fence_language_fall_back_to_plain_literals() {
4136        let blob = crate::highlight::literal_block("value `tick`\n", Some("blob.unknown"), None);
4137        assert!(!blob.contains("`FT"));
4138        assert!(blob.contains("value \\`tick\\`"));
4139
4140        let markdown = markdown_to_micron("```not-a-language\nvalue `tick`\n```\n");
4141        assert!(!markdown.contains("`FT"));
4142        assert!(markdown.contains("value \\`tick\\`"));
4143    }
4144
4145    #[test]
4146    fn binary_and_oversized_blobs_use_plain_fallback_messages() {
4147        let tmp = tempfile::tempdir().unwrap();
4148        let config = cfg(tmp.path());
4149        create_repo_bytes(
4150            config.repositories_dir.join("public/binary"),
4151            "payload.bin",
4152            b"abc\0def",
4153        );
4154        let large = vec![b'x'; 256 * 1024 + 1];
4155        create_repo_bytes(
4156            config.repositories_dir.join("public/large"),
4157            "payload.rs",
4158            &large,
4159        );
4160        let access = access(&config);
4161
4162        let binary = render_page(
4163            PATH_BLOB,
4164            &config,
4165            &access,
4166            &page_request(&[
4167                ("var_g", "public"),
4168                ("var_r", "binary"),
4169                ("var_path", "payload.bin"),
4170            ]),
4171            None,
4172        )
4173        .unwrap();
4174        assert!(binary.contains("Binary"));
4175        assert!(binary.contains("Binary file is not displayed."));
4176        assert!(!binary.contains("`FT"));
4177
4178        let oversized = render_page(
4179            PATH_BLOB,
4180            &config,
4181            &access,
4182            &page_request(&[
4183                ("var_g", "public"),
4184                ("var_r", "large"),
4185                ("var_path", "payload.rs"),
4186            ]),
4187            None,
4188        )
4189        .unwrap();
4190        assert!(oversized.contains("File is too large to display"));
4191        assert!(!oversized.contains("`FT"));
4192    }
4193
4194    #[test]
4195    fn markdown_blob_defaults_to_rendered_view_and_raw_view_escapes_source() {
4196        let tmp = tempfile::tempdir().unwrap();
4197        let config = cfg(tmp.path());
4198        create_repo(
4199            config.repositories_dir.join("public/docs"),
4200            "docs/readme.md",
4201            "# Title\n\nSee [docs](https://example.invalid), [guide](guide.md#intro) and `tick`.\n",
4202        );
4203        let access = access(&config);
4204
4205        let rendered = render_page(
4206            PATH_BLOB,
4207            &config,
4208            &access,
4209            &page_request(&[
4210                ("var_g", "public"),
4211                ("var_r", "docs"),
4212                ("var_path", "docs/readme.md"),
4213            ]),
4214            None,
4215        )
4216        .unwrap();
4217        assert!(rendered.contains("Displaying Rendered"));
4218        assert!(rendered.contains("View raw"));
4219        assert!(rendered.contains(">Title"));
4220        assert!(rendered.contains("`_`!`[docs`https://example.invalid]`!`_"));
4221        assert!(rendered.contains(
4222            "`_`!`[guide`:/page/blob.mu`g=public|r=docs|ref=HEAD|path=docs/guide.md|anchor=intro]`!`_"
4223        ));
4224        assert!(rendered.contains("`BT383838`Fdddtick`f`b"));
4225        assert!(!rendered.contains("# Title"));
4226
4227        let raw = render_page(
4228            PATH_BLOB,
4229            &config,
4230            &access,
4231            &page_request(&[
4232                ("var_g", "public"),
4233                ("var_r", "docs"),
4234                ("var_path", "docs/readme.md"),
4235                ("var_raw", "y"),
4236            ]),
4237            None,
4238        )
4239        .unwrap();
4240        assert!(raw.contains("Displaying Raw"));
4241        assert!(raw.contains("View rendered"));
4242        assert!(raw.contains("Title"));
4243        assert!(raw.contains("\\`"));
4244        assert!(raw.contains("tick"));
4245        assert!(!raw.contains("`_`!`[docs`https://example.invalid]`!`_"));
4246    }
4247
4248    #[test]
4249    fn micron_blob_defaults_to_rendered_passthrough_and_raw_view_is_literal() {
4250        let tmp = tempfile::tempdir().unwrap();
4251        let config = cfg(tmp.path());
4252        create_repo(
4253            config.repositories_dir.join("public/micron"),
4254            "README.mu",
4255            ">Micron\n\n`!Already formatted`!\n",
4256        );
4257        let access = access(&config);
4258
4259        let rendered = render_page(
4260            PATH_BLOB,
4261            &config,
4262            &access,
4263            &page_request(&[
4264                ("var_g", "public"),
4265                ("var_r", "micron"),
4266                ("var_path", "README.mu"),
4267            ]),
4268            None,
4269        )
4270        .unwrap();
4271        assert!(rendered.contains("Displaying Rendered"));
4272        assert!(rendered.contains(">Micron"));
4273        assert!(rendered.contains("`!Already formatted`!"));
4274        assert!(!rendered.contains("\\`!Already formatted\\`!"));
4275
4276        let raw = render_page(
4277            PATH_BLOB,
4278            &config,
4279            &access,
4280            &page_request(&[
4281                ("var_g", "public"),
4282                ("var_r", "micron"),
4283                ("var_path", "README.mu"),
4284                ("var_raw", "y"),
4285            ]),
4286            None,
4287        )
4288        .unwrap();
4289        assert!(raw.contains("Displaying Raw"));
4290        assert!(raw.contains("\\`!Already formatted\\`!"));
4291    }
4292
4293    #[test]
4294    fn explicit_render_parameter_renders_markdown_blob() {
4295        let tmp = tempfile::tempdir().unwrap();
4296        let config = cfg(tmp.path());
4297        create_repo(
4298            config.repositories_dir.join("public/docs"),
4299            "docs/readme.md",
4300            "# Explicit\n",
4301        );
4302        let access = access(&config);
4303
4304        let rendered = render_page(
4305            PATH_BLOB,
4306            &config,
4307            &access,
4308            &page_request(&[
4309                ("var_g", "public"),
4310                ("var_r", "docs"),
4311                ("var_path", "docs/readme.md"),
4312                ("var_raw", "y"),
4313                ("var_render", "y"),
4314            ]),
4315            None,
4316        )
4317        .unwrap();
4318        assert!(rendered.contains("Displaying Raw"));
4319        assert!(rendered.contains("#"));
4320        assert!(rendered.contains("Explicit"));
4321
4322        let rendered = render_page(
4323            PATH_BLOB,
4324            &config,
4325            &access,
4326            &page_request(&[
4327                ("var_g", "public"),
4328                ("var_r", "docs"),
4329                ("var_path", "docs/readme.md"),
4330                ("var_render", "y"),
4331            ]),
4332            None,
4333        )
4334        .unwrap();
4335        assert!(rendered.contains("Displaying Rendered"));
4336        assert!(rendered.contains(">Explicit"));
4337        assert!(!rendered.contains("# Explicit"));
4338    }
4339
4340    #[test]
4341    fn unsupported_blob_extension_ignores_render_parameter_and_uses_raw_literal() {
4342        let tmp = tempfile::tempdir().unwrap();
4343        let config = cfg(tmp.path());
4344        create_repo(
4345            config.repositories_dir.join("public/text"),
4346            "notes.txt",
4347            "# Not a heading\npath\\name\t`literal`\n",
4348        );
4349        let access = access(&config);
4350
4351        let page = render_page(
4352            PATH_BLOB,
4353            &config,
4354            &access,
4355            &page_request(&[
4356                ("var_g", "public"),
4357                ("var_r", "text"),
4358                ("var_path", "notes.txt"),
4359                ("var_render", "y"),
4360            ]),
4361            None,
4362        )
4363        .unwrap();
4364        assert!(!page.contains("Displaying Rendered"));
4365        assert!(!page.contains("View raw"));
4366        assert!(page.contains("Displaying Raw"));
4367        assert!(page.contains("Download"));
4368        assert!(page.contains("Not a heading"));
4369        assert!(page.contains("path\\\\name   \\`literal\\`"));
4370        assert!(page.contains("\\`literal\\`"));
4371    }
4372
4373    #[test]
4374    fn renderable_binary_and_oversized_blobs_do_not_render_content() {
4375        let tmp = tempfile::tempdir().unwrap();
4376        let config = cfg(tmp.path());
4377        create_repo_bytes(
4378            config.repositories_dir.join("public/binary-md"),
4379            "payload.md",
4380            b"# Binary\0payload\n",
4381        );
4382        let large = vec![b'#'; 256 * 1024 + 1];
4383        create_repo_bytes(
4384            config.repositories_dir.join("public/large-md"),
4385            "payload.md",
4386            &large,
4387        );
4388        let access = access(&config);
4389
4390        let binary = render_page(
4391            PATH_BLOB,
4392            &config,
4393            &access,
4394            &page_request(&[
4395                ("var_g", "public"),
4396                ("var_r", "binary-md"),
4397                ("var_path", "payload.md"),
4398            ]),
4399            None,
4400        )
4401        .unwrap();
4402        assert!(binary.contains("Binary"));
4403        assert!(binary.contains("Binary file is not displayed."));
4404        assert!(!binary.contains(">Binary"));
4405
4406        let oversized = render_page(
4407            PATH_BLOB,
4408            &config,
4409            &access,
4410            &page_request(&[
4411                ("var_g", "public"),
4412                ("var_r", "large-md"),
4413                ("var_path", "payload.md"),
4414                ("var_render", "y"),
4415            ]),
4416            None,
4417        )
4418        .unwrap();
4419        assert!(oversized.contains("File is too large to display"));
4420        assert!(!oversized.contains("Displaying Rendered\n\n>"));
4421    }
4422
4423    #[test]
4424    fn code_blocks_render_plain_literal_blocks() {
4425        let blob = crate::highlight::literal_block("pub fn main() {}\n", Some("main.rs"), None);
4426        assert!(!blob.contains("`FT"));
4427        assert!(blob.contains("pub fn main() {}"));
4428
4429        let markdown = markdown_to_micron("```rust\npub fn main() {}\n```\n");
4430        assert!(!markdown.contains("`FT"));
4431        assert!(markdown.contains("pub fn main() {}"));
4432    }
4433
4434    #[test]
4435    fn repo_page_renders_markdown_readme_and_passes_micron_readme_through() {
4436        let tmp = tempfile::tempdir().unwrap();
4437        let config = cfg(tmp.path());
4438        create_repo(
4439            config.repositories_dir.join("public/markdown"),
4440            "README.md",
4441            "# Markdown\n\nSee [docs](https://example.invalid) and [local](docs/intro.md).\n",
4442        );
4443        create_repo(
4444            config.repositories_dir.join("public/micron"),
4445            "README.mu",
4446            ">Micron\n\n`!Already formatted`!\n",
4447        );
4448        create_repo(
4449            config.repositories_dir.join("public/microntight"),
4450            "README.mu",
4451            ">Micron without trailing newline",
4452        );
4453        create_repo(
4454            config.repositories_dir.join("public/micronblank"),
4455            "README.mu",
4456            ">Micron with trailing blanks\n\n\n",
4457        );
4458        let access = access(&config);
4459
4460        let markdown = render_page(
4461            PATH_REPO,
4462            &config,
4463            &access,
4464            &page_request(&[("var_g", "public"), ("var_r", "markdown")]),
4465            None,
4466        )
4467        .unwrap();
4468        assert!(markdown.contains(">Markdown"));
4469        assert!(markdown.contains("`_`!`[docs`https://example.invalid]`!`_"));
4470        assert!(markdown.contains(
4471            "`_`!`[local`:/page/blob.mu`g=public|r=markdown|ref=HEAD|path=docs/intro.md]`!`_"
4472        ));
4473
4474        let micron = render_page(
4475            PATH_REPO,
4476            &config,
4477            &access,
4478            &page_request(&[("var_g", "public"), ("var_r", "micron")]),
4479            None,
4480        )
4481        .unwrap();
4482        assert!(micron.contains(">Micron"));
4483        assert!(micron.contains("`!Already formatted`!"));
4484
4485        let micron = render_page(
4486            PATH_REPO,
4487            &config,
4488            &access,
4489            &page_request(&[("var_g", "public"), ("var_r", "microntight")]),
4490            None,
4491        )
4492        .unwrap();
4493        assert!(micron.contains(">Micron without trailing newline\n"));
4494        assert!(!micron.contains(">Micron without trailing newline<"));
4495
4496        let micron = render_page(
4497            PATH_REPO,
4498            &config,
4499            &access,
4500            &page_request(&[("var_g", "public"), ("var_r", "micronblank")]),
4501            None,
4502        )
4503        .unwrap();
4504        assert!(micron.contains(">Micron with trailing blanks\n"));
4505        assert!(!micron.contains(">Micron with trailing blanks\n\n\n"));
4506    }
4507
4508    #[test]
4509    fn work_pages_render_documents_comments_and_repo_links() {
4510        let tmp = tempfile::tempdir().unwrap();
4511        let config = cfg(tmp.path());
4512        let repo_path = create_repo(
4513            config.repositories_dir.join("public/worked"),
4514            "README.md",
4515            "# Worked\n",
4516        );
4517        let work_path = crate::work::work_sidecar_path(&repo_path);
4518        let created = crate::work::create_document(
4519            &work_path,
4520            crate::work::WorkInput {
4521                title: "Active task".into(),
4522                content: "# Active Body\n".into(),
4523                format: "markdown".into(),
4524                signature: None,
4525                identity: None,
4526                author: [0x11; 16],
4527            },
4528        )
4529        .unwrap();
4530        crate::work::add_comment(
4531            &work_path,
4532            crate::work::WorkScope::Active,
4533            created.id,
4534            crate::work::WorkCommentInput {
4535                content: "Progress update".into(),
4536                format: "markdown".into(),
4537                signature: None,
4538                author: [0x22; 16],
4539            },
4540        )
4541        .unwrap();
4542        let completed = crate::work::create_document(
4543            &work_path,
4544            crate::work::WorkInput {
4545                title: "Completed task".into(),
4546                content: "Done".into(),
4547                format: "micron".into(),
4548                signature: None,
4549                identity: None,
4550                author: [0x11; 16],
4551            },
4552        )
4553        .unwrap();
4554        crate::work::complete_document(&work_path, completed.id, &[0x11; 16]).unwrap();
4555        let access = access(&config);
4556
4557        let repo = render_page(
4558            PATH_REPO,
4559            &config,
4560            &access,
4561            &page_request(&[("var_g", "public"), ("var_r", "worked")]),
4562            None,
4563        )
4564        .unwrap();
4565        assert!(repo.contains("Work (2)"));
4566        assert!(repo.contains(PATH_WORK));
4567
4568        let active = render_page(
4569            PATH_WORK,
4570            &config,
4571            &access,
4572            &page_request(&[("var_g", "public"), ("var_r", "worked")]),
4573            None,
4574        )
4575        .unwrap();
4576        assert!(active.contains(">Active Work Documents (1)"));
4577        assert!(active.contains("#1 Active task"));
4578        assert!(active.contains("1 updates"));
4579        assert!(!active.contains("Completed task"));
4580
4581        let completed_page = render_page(
4582            PATH_WORK,
4583            &config,
4584            &access,
4585            &page_request(&[
4586                ("var_g", "public"),
4587                ("var_r", "worked"),
4588                ("var_scope", "completed"),
4589            ]),
4590            None,
4591        )
4592        .unwrap();
4593        assert!(completed_page.contains(">Completed Work Documents (1)"));
4594        assert!(completed_page.contains("Completed task"));
4595
4596        let doc = render_page(
4597            PATH_WORK_DOC,
4598            &config,
4599            &access,
4600            &page_request(&[("var_g", "public"), ("var_r", "worked"), ("var_id", "1")]),
4601            None,
4602        )
4603        .unwrap();
4604        assert!(doc.contains(">Active task"));
4605        assert!(doc.contains(">Active Body"));
4606        assert!(doc.contains(">Updates (1)"));
4607        assert!(doc.contains("Progress update"));
4608        assert!(doc.contains("`[Download`:/file/workdoc`g=public|r=worked|scope=active|id=1]"));
4609
4610        let completed_doc = render_page(
4611            PATH_WORK_DOC,
4612            &config,
4613            &access,
4614            &page_request(&[
4615                ("var_g", "public"),
4616                ("var_r", "worked"),
4617                ("var_id", &completed.id.to_string()),
4618            ]),
4619            None,
4620        )
4621        .unwrap();
4622        assert!(completed_doc.contains(">Completed task"));
4623        assert!(completed_doc.contains("Status: completed"));
4624        assert!(completed_doc.contains("`[work`:/page/work.mu`g=public|r=worked|scope=completed]"));
4625        assert!(completed_doc.contains(&format!(
4626            "`[Download`:/file/workdoc`g=public|r=worked|scope=completed|id={}]",
4627            completed.id
4628        )));
4629
4630        let active_download = download_work_document(
4631            &config,
4632            &access,
4633            &page_request(&[
4634                ("var_g", "public"),
4635                ("var_r", "worked"),
4636                ("var_scope", "active"),
4637                ("var_id", "1"),
4638            ]),
4639            None,
4640        )
4641        .unwrap();
4642        assert_resource_response(active_download, b"# Active Body");
4643
4644        let completed_download = download_work_document(
4645            &config,
4646            &access,
4647            &page_request(&[
4648                ("var_g", "public"),
4649                ("var_r", "worked"),
4650                ("var_id", &completed.id.to_string()),
4651            ]),
4652            None,
4653        )
4654        .unwrap();
4655        assert_resource_response(completed_download, b"Done");
4656    }
4657
4658    #[test]
4659    fn work_pages_respect_read_access() {
4660        let tmp = tempfile::tempdir().unwrap();
4661        let mut config = cfg(tmp.path());
4662        create_repo(
4663            config.repositories_dir.join("public/private-work"),
4664            "README.md",
4665            "# Private\n",
4666        );
4667        config.allow_read = vec!["none".into()];
4668        let access = access(&config);
4669
4670        assert!(render_page(
4671            PATH_WORK,
4672            &config,
4673            &access,
4674            &page_request(&[("var_g", "public"), ("var_r", "private-work")]),
4675            None,
4676        )
4677        .is_err());
4678    }
4679
4680    fn cfg(root: &std::path::Path) -> ServerConfig {
4681        ServerConfig {
4682            dir: root.to_path_buf(),
4683            reticulum_dir: None,
4684            repositories_dir: root.join("repositories"),
4685            identity_path: root.join("repositories_identity"),
4686            client_identity_path: root.join("client_identity"),
4687            node_name: "Test Git Node".into(),
4688            announce_interval_secs: 300,
4689            serve_nomadnet: true,
4690            templates_dir: root.join("templates"),
4691            unicode_icons: false,
4692            record_stats: false,
4693            stats_ignore_identities: Vec::new(),
4694            stats_push_ignore_identities: Vec::new(),
4695            blocked_identities: Vec::new(),
4696            identity_aliases: std::collections::BTreeMap::new(),
4697            allow_read: vec!["all".into()],
4698            allow_write: vec!["none".into()],
4699            allow_create: vec!["none".into()],
4700            allow_stats: vec!["none".into()],
4701            allow_release: vec!["none".into()],
4702            allow_interact: vec!["none".into()],
4703            allow_propose: vec!["none".into()],
4704            allow_admin: vec!["none".into()],
4705            log_level: logging::DEFAULT_LOG_LEVEL,
4706        }
4707    }
4708
4709    fn access(config: &ServerConfig) -> Access {
4710        Access::new(
4711            &config.allow_read,
4712            &config.allow_write,
4713            &config.allow_create,
4714            &config.allow_stats,
4715            &config.allow_release,
4716            &config.allow_interact,
4717            &config.allow_admin,
4718            config.repositories_dir.clone(),
4719        )
4720        .unwrap()
4721    }
4722
4723    fn page_request(fields: &[(&str, &str)]) -> Vec<u8> {
4724        msgpack::pack(&Value::Map(
4725            fields
4726                .iter()
4727                .map(|(k, v)| (Value::Str((*k).into()), Value::Str((*v).into())))
4728                .collect(),
4729        ))
4730    }
4731
4732    fn assert_resource_response(response: RequestResponse, expected: &[u8]) {
4733        match response {
4734            RequestResponse::Resource { data, metadata, .. } => {
4735                assert_eq!(data, expected);
4736                assert_eq!(
4737                    metadata.as_deref(),
4738                    Some(&protocol::metadata_status(protocol::RES_OK)[..])
4739                );
4740            }
4741            RequestResponse::Bytes(bytes) => panic!("expected resource response, got {bytes:?}"),
4742        }
4743    }
4744
4745    fn create_repo(path: std::path::PathBuf, file: &str, content: &str) -> std::path::PathBuf {
4746        create_repo_bytes(path, file, content.as_bytes())
4747    }
4748
4749    fn create_repo_bytes(
4750        path: std::path::PathBuf,
4751        file: &str,
4752        content: &[u8],
4753    ) -> std::path::PathBuf {
4754        let work = path.with_extension("work");
4755        fs::create_dir_all(&work).unwrap();
4756        run_git(Command::new("git").arg("init").arg(&work));
4757        let file_path = work.join(file);
4758        fs::create_dir_all(file_path.parent().unwrap()).unwrap();
4759        fs::write(&file_path, content).unwrap();
4760        run_git(Command::new("git").arg("-C").arg(&work).arg("add").arg("."));
4761        run_git(
4762            Command::new("git")
4763                .arg("-C")
4764                .arg(&work)
4765                .arg("-c")
4766                .arg("user.name=RNS Page Test")
4767                .arg("-c")
4768                .arg("user.email=rns-page-test@example.invalid")
4769                .arg("commit")
4770                .arg("-m")
4771                .arg("initial"),
4772        );
4773        run_git(
4774            Command::new("git")
4775                .arg("-C")
4776                .arg(&work)
4777                .args(["branch", "-M", "main"]),
4778        );
4779        run_git(
4780            Command::new("git")
4781                .arg("clone")
4782                .arg("--bare")
4783                .arg(&work)
4784                .arg(&path),
4785        );
4786        path
4787    }
4788
4789    fn run_git(cmd: &mut Command) -> String {
4790        let output = cmd.output().unwrap();
4791        assert!(
4792            output.status.success(),
4793            "git command failed: {}",
4794            String::from_utf8_lossy(&output.stderr)
4795        );
4796        String::from_utf8_lossy(&output.stdout).into_owned()
4797    }
4798}