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