diff --git a/CHANGELOG.md b/CHANGELOG.md index d65228c..b7507eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project uses [independent versioning](README.md#versioning) for Framewo --- +## CLI 3.2.4 — Unicode-Safe Rendering Across TUI and Commands + +### Fixed (CLI) +- Scrollbar in `devtrail explore` no longer leaks document text through the track; the document body now renders in a dedicated column and the scrollbar thumb has a correct viewport-proportional size. +- `devtrail explore` navigation tree, metadata panel, status bar, and Markdown code blocks now measure text in visual columns (via `unicode-width`) instead of bytes. Titles, tags, related-document links, paths, and the status bar all stay aligned with CJK, accented characters, and emoji. +- `devtrail validate`: filename-date parsing is now UTF-8-safe. Filenames with multi-byte characters where ASCII was expected fail with a clean `NAMING-001` error instead of risking a byte-boundary panic. +- `devtrail analyze` and `devtrail status` tables no longer misalign when paths, function names, or project directories contain non-ASCII characters. +- `devtrail new`: sequence-number and slug computation switched from byte slicing to char-safe operations. + +### Changed (CLI) +- `unicode-width` is now a direct (always-compiled) dependency. Previously it was an optional transitive dep under feature `tui`. +- New shared helpers `visual_width`, `truncate_visual`, and `pad_right_visual` in `utils.rs`, used by every layout site that previously confused bytes with columns. + +--- + ## CLI 3.2.3 — UTF-8 Crash Fix in `explore` Tables ### Fixed (CLI) diff --git a/README.md b/README.md index 281fe71..e02375b 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ DevTrail uses independent version tags for each component: | Component | Tag prefix | Example | Includes | |-----------|-----------|---------|----------| | Framework | `fw-` | `fw-4.2.0` | Templates (12 types), governance, directives | -| CLI | `cli-` | `cli-3.2.3` | The `devtrail` binary | +| CLI | `cli-` | `cli-3.2.4` | The `devtrail` binary | Check installed versions with `devtrail status` or `devtrail about`. diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 34fb394..97e7741 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -537,7 +537,7 @@ dependencies = [ [[package]] name = "devtrail-cli" -version = "3.2.3" +version = "3.2.4" dependencies = [ "anyhow", "arborist-metrics", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index cb6f258..2c17afc 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "devtrail-cli" -version = "3.2.3" +version = "3.2.4" edition = "2021" description = "CLI tool for DevTrail - Documentation Governance for AI-Assisted Development" license = "MIT" @@ -33,15 +33,15 @@ chrono = { version = "0.4", default-features = false, features = ["std", "clock" semver = "1" flate2 = "1" tar = "0.4" +unicode-width = "0.2" ratatui = { version = "0.29", optional = true, default-features = false, features = ["crossterm"] } crossterm = { version = "0.28", optional = true } pulldown-cmark = { version = "0.12", optional = true } -unicode-width = { version = "0.2", optional = true } arborist-metrics = { version = "0.1", optional = true, features = ["all"] } [features] default = ["tui", "analyze"] -tui = ["ratatui", "crossterm", "pulldown-cmark", "unicode-width"] +tui = ["ratatui", "crossterm", "pulldown-cmark"] analyze = ["arborist-metrics"] [dev-dependencies] diff --git a/cli/src/commands/analyze.rs b/cli/src/commands/analyze.rs index e355947..d8bb836 100644 --- a/cli/src/commands/analyze.rs +++ b/cli/src/commands/analyze.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use crate::analysis_engine::{self, AnalysisReport, FunctionEntry}; use crate::config::DevTrailConfig; -use crate::utils; +use crate::utils::{self, pad_right_visual, truncate_visual, visual_width}; pub fn run(path: &str, threshold: Option, output: &str, top: Option) -> Result<()> { let target = PathBuf::from(path) @@ -73,9 +73,9 @@ fn print_text(report: &AnalysisReport, target: &std::path::Path) { ); println!(); println!( - " {:<40} {:<25} {:>5} {:>5} {:>5} {:>5}", - "FILE".dimmed(), - "FUNCTION".dimmed(), + " {} {} {:>5} {:>5} {:>5} {:>5}", + pad_right_visual("FILE", 40).dimmed(), + pad_right_visual("FUNCTION", 25).dimmed(), "LINE".dimmed(), "COGN".dimmed(), "CYCL".dimmed(), @@ -90,9 +90,9 @@ fn print_text(report: &AnalysisReport, target: &std::path::Path) { cogn_str.yellow().bold() }; println!( - " {:<40} {:<25} {:>5} {:>5} {:>5} {:>5}", + " {} {} {:>5} {:>5} {:>5} {:>5}", truncate_path(&func.file, 40), - truncate_str(&func.name, 25), + pad_right_visual(&truncate_visual(&func.name, 25), 25), func.line, cogn_colored, func.cyclomatic, @@ -216,21 +216,31 @@ fn print_markdown(report: &AnalysisReport, target: &std::path::Path) { } } -/// Truncate a path string to fit within a given width +/// Truncate a path string to exactly `max` visual columns, preserving the +/// tail (most meaningful part of a path) with a leading "…". The result is +/// right-padded so the column is always `max` columns wide. fn truncate_path(s: &str, max: usize) -> String { - if s.len() <= max { - format!("{: String { - if s.len() <= max { - format!("{: budget { + break; + } + used += w; + keep_from = byte_idx; } + let mut out = String::with_capacity(s.len() - keep_from + 3); + out.push('…'); + out.push_str(&s[keep_from..]); + pad_right_visual(&out, max) } diff --git a/cli/src/commands/new.rs b/cli/src/commands/new.rs index b7b9116..6267e06 100644 --- a/cli/src/commands/new.rs +++ b/cli/src/commands/new.rs @@ -137,8 +137,12 @@ fn slugify(title: &str) -> String { .filter(|s| !s.is_empty()) .collect(); let slug = parts.join("-"); - if slug.len() > 50 { - slug[..50].trim_end_matches('-').to_string() + // `slug` is built exclusively from ASCII alphanumerics joined by '-', + // so every char is 1 byte and byte-slicing the first 50 is safe. The + // `chars().take(50)` form keeps us robust if the filter ever changes. + if slug.chars().count() > 50 { + let truncated: String = slug.chars().take(50).collect(); + truncated.trim_end_matches('-').to_string() } else { slug } @@ -154,8 +158,11 @@ fn next_sequence_number(doc_dir: &std::path::Path, doc_type: DocType, today: &st let name = entry.file_name(); let name = name.to_str().unwrap_or(""); if let Some(rest) = name.strip_prefix(&prefix_pattern) { - if rest.len() >= 3 { - if let Ok(n) = rest[..3].parse::() { + // Take the first 3 chars safely; they must all be ASCII + // digits for the sequence to be valid. + let head: String = rest.chars().take(3).collect(); + if head.chars().count() == 3 { + if let Ok(n) = head.parse::() { max_seq = max_seq.max(n); } } diff --git a/cli/src/commands/status.rs b/cli/src/commands/status.rs index 6bf29bd..0326f9c 100644 --- a/cli/src/commands/status.rs +++ b/cli/src/commands/status.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use crate::config::DevTrailConfig; use crate::manifest::DistManifest; -use crate::utils; +use crate::utils::{self, pad_right_visual, visual_width}; /// Expected directories inside .devtrail/ const EXPECTED_DIRS: &[&str] = &[ @@ -90,14 +90,22 @@ pub fn run(path: &str) -> Result<()> { ("CLI", format!("cli-{}", cli_version)), ("Language", language.clone()), ]; - let label_w = project_rows.iter().map(|(l, _)| l.len()).max().unwrap_or(5); - let value_w = project_rows.iter().map(|(_, v)| v.len()).max().unwrap_or(10); + let label_w = project_rows + .iter() + .map(|(l, _)| visual_width(l)) + .max() + .unwrap_or(5); + let value_w = project_rows + .iter() + .map(|(_, v)| visual_width(v)) + .max() + .unwrap_or(10); print_border(" ┌", label_w, "┬", value_w, "┐"); for (label, value) in &project_rows { println!( - " │ {: Result<()> { ); } - // Calculate column widths dynamically + // Calculate column widths dynamically, measured in visual columns. let name_w = struct_items .iter() - .map(|(name, _)| name.len()) + .map(|(name, _)| visual_width(name)) .max() .unwrap_or(10) - .max("Directory / File".len()); + .max(visual_width("Directory / File")); let status_w = 6; // "✓ OK " or "✗ -- " println!(); println!( - " {: Result<()> { for (name, exists) in &struct_items { let status_text = if *exists { "✓ OK" } else { "✗ --" }; - let plain_row = format!(" {: Result<()> { let type_w = DOC_TYPES .iter() - .map(|(p, l)| format!("{:<6}{}", p, l).len()) + .map(|(p, l)| visual_width(&format!("{p:<6}{l}"))) .max() .unwrap_or(20) - .max("Type".len()); + .max(visual_width("Type")); let count_w = 5; println!(); println!( - " {: Result<()> { for (prefix, label, count) in &counts { let display = format!("{prefix:<6}{label}"); let count_str = format!("{count:>count_w$}"); + let padded = pad_right_visual(&display, type_w); if *count > 0 { - println!( - " {:count_w$}"); println!( - " {: Vec { in_code_block = false; - // Calculate uniform width: max line length + padding - let max_len = code_block_lines + // Measure in visual columns so CJK/emoji don't break alignment. + let max_cols = code_block_lines .iter() - .map(|l| l.len()) + .map(|l| UnicodeWidthStr::width(l.as_str())) .max() .unwrap_or(0); let code_bg = Style::default() @@ -164,7 +164,9 @@ pub fn markdown_to_lines(markdown: &str, available_width: usize) -> Vec> = Vec::new(); if content_indent > 0 { spans.push(Span::raw(" ".repeat(content_indent))); diff --git a/cli/src/tui/widgets/doc_viewer.rs b/cli/src/tui/widgets/doc_viewer.rs index b249028..492e483 100644 --- a/cli/src/tui/widgets/doc_viewer.rs +++ b/cli/src/tui/widgets/doc_viewer.rs @@ -3,6 +3,7 @@ use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget, Wrap}; +use unicode_width::UnicodeWidthStr; use crate::tui::app::{ActivePanel, App}; use crate::tui::markdown::markdown_to_lines; @@ -60,20 +61,50 @@ impl<'a> DocViewer<'a> { let doc = self.app.current_doc.as_ref().unwrap(); + // Reserve 1 column on the right for the scrollbar so it doesn't + // overlap the document text. Without this, the uncovered track + // leaks the underlying text through where there is no thumb. + let reserve_scrollbar = inner.width >= 2; + let body_area = if reserve_scrollbar { + Rect { + x: inner.x, + y: inner.y, + width: inner.width - 1, + height: inner.height, + } + } else { + inner + }; + let scrollbar_area = if reserve_scrollbar { + Rect { + x: inner.x + inner.width - 1, + y: inner.y, + width: 1, + height: inner.height, + } + } else { + inner + }; + // Render markdown body only (metadata is in separate panel) + let body_width = body_area.width.max(1) as usize; let mut all_lines = vec![Line::from(""); 2]; - all_lines.extend(markdown_to_lines(&doc.body, inner.width as usize)); + all_lines.extend(markdown_to_lines(&doc.body, body_width)); - // Estimate total lines accounting for wrapping - let width = inner.width.max(1) as usize; + // Estimate total lines accounting for wrapping, measured in visual + // columns (CJK and other double-wide chars count as 2). let wrapped_count: usize = all_lines .iter() .map(|line| { - let line_width: usize = line.spans.iter().map(|s| s.content.len()).sum(); + let line_width: usize = line + .spans + .iter() + .map(|s| UnicodeWidthStr::width(s.content.as_ref())) + .sum(); if line_width == 0 { 1 } else { - (line_width + width - 1) / width + line_width.div_ceil(body_width) } }) .sum(); @@ -83,16 +114,17 @@ impl<'a> DocViewer<'a> { let paragraph = Paragraph::new(text) .wrap(Wrap { trim: false }) .scroll((self.app.doc_scroll, 0)); - paragraph.render(inner, buf); + paragraph.render(body_area, buf); - // Render scrollbar - if self.app.doc_total_lines > inner.height as usize { + // Render scrollbar in the reserved column. + if self.app.doc_total_lines > body_area.height as usize { let mut scrollbar_state = ScrollbarState::new(self.app.doc_total_lines) + .viewport_content_length(body_area.height as usize) .position(self.app.doc_scroll as usize); let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) .begin_symbol(Some("↑")) .end_symbol(Some("↓")); - scrollbar.render(inner, buf, &mut scrollbar_state); + scrollbar.render(scrollbar_area, buf, &mut scrollbar_state); } } } diff --git a/cli/src/tui/widgets/metadata_panel.rs b/cli/src/tui/widgets/metadata_panel.rs index 1cc86c3..da7915b 100644 --- a/cli/src/tui/widgets/metadata_panel.rs +++ b/cli/src/tui/widgets/metadata_panel.rs @@ -7,6 +7,7 @@ use ratatui::widgets::{Block, Borders, Paragraph, Widget, Wrap}; use crate::tui::app::{ActivePanel, App, MetaSelection}; use crate::tui::document::{ConfidenceLevel, DocStatus, RiskLevel}; use crate::tui::theme; +use crate::utils::truncate_visual; pub struct MetadataPanel<'a> { app: &'a App, @@ -204,7 +205,7 @@ impl Widget for MetadataPanel<'_> { .fg(theme::TEXT) .add_modifier(Modifier::UNDERLINED) }; - let display = truncate_str(rel, max_link_width); + let display = truncate_visual(rel, max_link_width); lines.push(Line::from(vec![ Span::styled(marker, l), Span::styled(display, style), @@ -270,14 +271,3 @@ fn risk_bar(level: &RiskLevel) -> (usize, usize, Color, &'static str) { } } -fn truncate_str(s: &str, max: usize) -> String { - let char_count: usize = s.chars().count(); - if char_count <= max { - s.to_string() - } else if max > 3 { - let truncated: String = s.chars().take(max - 3).collect(); - format!("{truncated}...") - } else { - s.chars().take(max).collect() - } -} diff --git a/cli/src/tui/widgets/nav_tree.rs b/cli/src/tui/widgets/nav_tree.rs index a067451..b2c9fbc 100644 --- a/cli/src/tui/widgets/nav_tree.rs +++ b/cli/src/tui/widgets/nav_tree.rs @@ -7,6 +7,7 @@ use ratatui::widgets::{Block, Borders, Paragraph, Widget}; use crate::tui::app::{ActivePanel, App, NavSelection, SortOrder}; use crate::tui::index::DocEntry; use crate::tui::theme; +use crate::utils::{truncate_visual, visual_width}; pub struct NavTree<'a> { app: &'a App, @@ -235,23 +236,26 @@ fn file_entry_line(entry: &DocEntry, indent: &str, max_width: usize, selected: b let date_style = Style::default().fg(theme::TEXT_DIM); let has_badge = !entry.doc_type.is_empty(); - let badge_len = if has_badge { entry.doc_type.len() + 1 } else { 0 }; // badge + space - - // Compact date: show MM-DD from YYYY-MM-DD - let date = if entry.created.len() >= 10 { - format!(" {}", &entry.created[5..10]) - } else { - String::new() - }; - let date_len = date.len(); - - let indent_len = indent.len(); + // Badge (`doc_type`) is drawn from hardcoded ASCII prefixes, so its + // visual width equals its char count; `+ 1` accounts for the trailing + // space between badge and title. + let badge_cols = if has_badge { visual_width(&entry.doc_type) + 1 } else { 0 }; + + // Compact date: show MM-DD from YYYY-MM-DD. Only render the slice when + // the prefix is ASCII so we never cut through a multi-byte boundary — + // anything else is treated as missing. + let date = extract_mmdd(&entry.created).map(|d| format!(" {d}")).unwrap_or_default(); + let date_cols = visual_width(&date); + + // `indent` is hardcoded ASCII spaces, so byte len == visual cols here, + // but we use visual_width for consistency. + let indent_cols = visual_width(indent); let title_budget = max_width - .saturating_sub(indent_len) - .saturating_sub(badge_len) - .saturating_sub(date_len); + .saturating_sub(indent_cols) + .saturating_sub(badge_cols) + .saturating_sub(date_cols); - let title = truncate_str(&entry.title, title_budget); + let title = truncate_visual(&entry.title, title_budget); let mut spans = vec![Span::raw(indent.to_string())]; if has_badge { @@ -287,16 +291,22 @@ fn count_group_docs(group: &crate::tui::index::DocGroup) -> usize { direct + sub } -fn truncate_str(name: &str, max_width: usize) -> String { - let char_count: usize = name.chars().count(); - if char_count <= max_width { - name.to_string() - } else if max_width > 3 { - let truncated: String = name.chars().take(max_width - 3).collect(); - format!("{truncated}...") - } else { - name.chars().take(max_width).collect() +/// Extract the "MM-DD" slice from an ISO-8601 "YYYY-MM-DD[...]" string. +/// Returns `None` if the input is shorter than 10 chars, contains any +/// non-ASCII character in the first 10 positions, or the shape doesn't +/// match (dashes at positions 4 and 7). This keeps us safe even if the +/// `created` field came from user-edited frontmatter with exotic input. +fn extract_mmdd(created: &str) -> Option { + let mut chars = created.chars(); + let head: String = (&mut chars).take(10).collect(); + if head.chars().count() < 10 || !head.is_ascii() { + return None; + } + let bytes = head.as_bytes(); + if bytes[4] != b'-' || bytes[7] != b'-' { + return None; } + Some(format!("{}-{}", &head[5..7], &head[8..10])) } fn subgroup_has_search_matches( diff --git a/cli/src/tui/widgets/status_bar.rs b/cli/src/tui/widgets/status_bar.rs index 3cf2beb..2ff5b4b 100644 --- a/cli/src/tui/widgets/status_bar.rs +++ b/cli/src/tui/widgets/status_bar.rs @@ -6,6 +6,7 @@ use ratatui::widgets::Widget; use crate::tui::app::App; use crate::tui::theme; +use crate::utils::visual_width; pub struct StatusBar<'a> { app: &'a App, @@ -93,10 +94,11 @@ impl Widget for StatusBar<'_> { .and_then(|n| n.to_str()) .unwrap_or("?"); let right_str = format!(" {} │ {} docs ", path_display, self.app.index.total_docs); - let used_width: usize = spans.iter().map(|s| s.content.len()).sum(); - let remaining = area.width as usize - used_width.min(area.width as usize); - if remaining > right_str.len() { - let padding = remaining - right_str.len(); + let used_width: usize = spans.iter().map(|s| visual_width(s.content.as_ref())).sum(); + let remaining = (area.width as usize).saturating_sub(used_width); + let right_cols = visual_width(&right_str); + if remaining > right_cols { + let padding = remaining - right_cols; spans.push(Span::styled(" ".repeat(padding), Style::default())); spans.push(Span::styled( format!(" {} ", path_display), diff --git a/cli/src/utils.rs b/cli/src/utils.rs index 132555e..04571d7 100644 --- a/cli/src/utils.rs +++ b/cli/src/utils.rs @@ -1,6 +1,7 @@ use colored::Colorize; use sha2::{Digest, Sha256}; use std::path::Path; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; /// Print a success message pub fn success(msg: &str) { @@ -102,3 +103,129 @@ pub fn resolve_project_root(path: &str) -> Option { None } + +/// Visual width of a string in terminal columns, accounting for double-wide +/// characters (CJK, some emoji). This is the unit every TUI layout should +/// use — `.len()` measures bytes and `.chars().count()` measures code points, +/// neither of which matches how a terminal renders text. +pub fn visual_width(s: &str) -> usize { + UnicodeWidthStr::width(s) +} + +/// Truncate `s` to fit within `max_cols` visual columns, appending "…" +/// (1 column) when truncation happens. Guarantees the returned string's +/// `visual_width()` is `<= max_cols` and that every byte offset used is a +/// valid UTF-8 char boundary. +#[cfg_attr(not(any(feature = "tui", feature = "analyze")), allow(dead_code))] +pub fn truncate_visual(s: &str, max_cols: usize) -> String { + if max_cols == 0 { + return String::new(); + } + if visual_width(s) <= max_cols { + return s.to_string(); + } + // Reserve 1 column for the ellipsis when there's room for it. + let budget = max_cols.saturating_sub(1); + let mut used = 0usize; + let mut cut_at = 0usize; + for (byte_idx, ch) in s.char_indices() { + let w = UnicodeWidthChar::width(ch).unwrap_or(0); + if used + w > budget { + cut_at = byte_idx; + break; + } + used += w; + cut_at = byte_idx + ch.len_utf8(); + } + let mut out = String::with_capacity(cut_at + 3); + out.push_str(&s[..cut_at]); + out.push('…'); + out +} + +/// Right-pad `s` with ASCII spaces so its visual width is exactly `cols`. +/// If `s` is already at least that wide, return it unchanged. Unlike +/// `format!("{: String { + let w = visual_width(s); + if w >= cols { + return s.to_string(); + } + let mut out = String::with_capacity(s.len() + (cols - w)); + out.push_str(s); + out.extend(std::iter::repeat_n(' ', cols - w)); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn visual_width_ascii() { + assert_eq!(visual_width("hello"), 5); + assert_eq!(visual_width(""), 0); + } + + #[test] + fn visual_width_accents_one_col_each() { + assert_eq!(visual_width("áéíóú"), 5); + } + + #[test] + fn visual_width_cjk_two_cols_each() { + assert_eq!(visual_width("数据"), 4); + } + + #[test] + fn truncate_visual_short_returns_as_is() { + assert_eq!(truncate_visual("hello", 10), "hello"); + } + + #[test] + fn truncate_visual_ascii_truncates_with_ellipsis() { + let out = truncate_visual("hello world", 8); + assert!(visual_width(&out) <= 8); + assert!(out.ends_with('…')); + } + + #[test] + fn truncate_visual_cjk_respects_double_width() { + // 数据表格 (4 ideograms, visual width 8). Budget 5 → must fit with ellipsis. + let out = truncate_visual("数据表格", 5); + assert!(visual_width(&out) <= 5); + assert!(std::str::from_utf8(out.as_bytes()).is_ok()); + } + + #[test] + fn truncate_visual_em_dash_no_panic() { + let s = "Partially mitigated — RLS is not active until middleware"; + for w in [5usize, 10, 20, 67] { + let out = truncate_visual(s, w); + assert!(visual_width(&out) <= w, "{out:?} too wide for {w}"); + } + } + + #[test] + fn truncate_visual_zero_width() { + assert_eq!(truncate_visual("anything", 0), ""); + } + + #[test] + fn pad_right_visual_ascii() { + assert_eq!(pad_right_visual("hi", 5), "hi "); + } + + #[test] + fn pad_right_visual_cjk_counts_two_columns() { + // "数" has visual width 2. Padding to 5 should add 3 spaces. + let out = pad_right_visual("数", 5); + assert_eq!(visual_width(&out), 5); + assert!(out.ends_with(" ")); + } + + #[test] + fn pad_right_visual_already_wider_returns_as_is() { + assert_eq!(pad_right_visual("hello", 3), "hello"); + } +} diff --git a/cli/src/validation.rs b/cli/src/validation.rs index 880042a..09186d5 100644 --- a/cli/src/validation.rs +++ b/cli/src/validation.rs @@ -230,8 +230,10 @@ fn check_naming(result: &mut ValidationResult, doc: &DevTrailDocument) { } }; - // Check date part - if after_prefix.len() < 10 { + // Check date part. We only slice by bytes once we've confirmed the + // first 10 characters are ASCII, so this is always UTF-8-safe. + let head: String = after_prefix.chars().take(10).collect(); + if head.chars().count() < 10 { result.add(ValidationIssue { file: doc.path.clone(), rule: "NAMING-001".to_string(), @@ -241,13 +243,24 @@ fn check_naming(result: &mut ValidationResult, doc: &DevTrailDocument) { }); return; } + if !head.is_ascii() { + result.add(ValidationIssue { + file: doc.path.clone(), + rule: "NAMING-001".to_string(), + message: format!("Invalid date in filename: '{}'", head), + severity: Severity::Error, + fix_hint: None, + }); + return; + } - let date_part = &after_prefix[..10]; - let valid_date = date_part.chars().nth(4) == Some('-') - && date_part.chars().nth(7) == Some('-') - && date_part[..4].chars().all(|c| c.is_ascii_digit()) - && date_part[5..7].chars().all(|c| c.is_ascii_digit()) - && date_part[8..10].chars().all(|c| c.is_ascii_digit()); + let date_part = head.as_str(); // exactly 10 ASCII bytes + let bytes = date_part.as_bytes(); + let valid_date = bytes[4] == b'-' + && bytes[7] == b'-' + && date_part[..4].bytes().all(|b| b.is_ascii_digit()) + && date_part[5..7].bytes().all(|b| b.is_ascii_digit()) + && date_part[8..10].bytes().all(|b| b.is_ascii_digit()); if !valid_date { result.add(ValidationIssue { @@ -260,7 +273,7 @@ fn check_naming(result: &mut ValidationResult, doc: &DevTrailDocument) { return; } - // Check sequence number after date + // Skip past the 10-byte date prefix (safe: we validated it's ASCII). let after_date = &after_prefix[10..]; if !after_date.starts_with('-') { result.add(ValidationIssue { @@ -543,18 +556,17 @@ fn check_date_consistency(result: &mut ValidationResult, doc: &DevTrailDocument) // Extract date from filename: after prefix dash, take 10 chars (YYYY-MM-DD) let prefix = doc.doc_type.prefix(); let after_prefix = match doc.filename.strip_prefix(&format!("{}-", prefix)) { - Some(rest) if rest.len() >= 10 => rest, + Some(rest) => rest, _ => return, }; + let filename_date: String = after_prefix.chars().take(10).collect(); + if filename_date.chars().count() < 10 { + return; + } - let filename_date = &after_prefix[..10]; - - // The created field may be a full datetime or just a date — compare the first 10 chars - let created_date = if created.len() >= 10 { - &created[..10] - } else { - created.as_str() - }; + // The created field may be a full datetime or just a date — take the + // first 10 chars safely (never slice by bytes on arbitrary input). + let created_date: String = created.chars().take(10).collect(); if filename_date != created_date { result.add(ValidationIssue { @@ -570,6 +582,8 @@ fn check_date_consistency(result: &mut ValidationResult, doc: &DevTrailDocument) } } + + /// Search for a document whose filename starts with the given id fn find_document_by_id(devtrail_dir: &Path, id: &str) -> bool { let docs = document::discover_documents(devtrail_dir); @@ -671,12 +685,16 @@ pub fn apply_fixes(doc: &DevTrailDocument) -> Option { if let Some(id) = &doc.frontmatter.id { let expected_prefix = doc.doc_type.prefix(); if !id.starts_with(expected_prefix) { - // Extract date-seq from filename + // Extract date-seq from filename. `dash_pos` comes from `find` + // so it's a valid char boundary; the 14-char slice below is + // taken via `chars().take()` to stay safe if `after_type` + // contains multi-byte characters. let name_no_ext = doc.filename.strip_suffix(".md").unwrap_or(&doc.filename); if let Some(dash_pos) = name_no_ext.find('-') { let after_type = &name_no_ext[dash_pos + 1..]; - if after_type.len() >= 14 { - let new_id = format!("{}-{}", expected_prefix, &after_type[..14]); + let head: String = after_type.chars().take(14).collect(); + if head.chars().count() == 14 { + let new_id = format!("{}-{}", expected_prefix, head); let old_id_line = format!("id: {}", id); let new_id_line = format!("id: {}", new_id); if new_content.contains(&old_id_line) { diff --git a/docs/adopters/CLI-REFERENCE.md b/docs/adopters/CLI-REFERENCE.md index 16b49e8..bc1e9c3 100644 --- a/docs/adopters/CLI-REFERENCE.md +++ b/docs/adopters/CLI-REFERENCE.md @@ -49,7 +49,7 @@ DevTrail uses **independent version tags** for each component: | Component | Tag prefix | Example | What it includes | |-----------|-----------|---------|------------------| | Framework | `fw-` | `fw-4.2.0` | Templates (12 types), governance docs, directives | -| CLI | `cli-` | `cli-3.2.3` | The `devtrail` binary | +| CLI | `cli-` | `cli-3.2.4` | The `devtrail` binary | Framework and CLI are released independently. A framework update does not require a CLI update, and vice versa. @@ -110,7 +110,7 @@ $ devtrail update Updating framework... ✔ Framework updated to fw-4.2.0 Updating CLI... -✔ CLI updated to cli-3.2.3 +✔ CLI updated to cli-3.2.4 ``` --- @@ -143,11 +143,11 @@ Use `--method` to override auto-detection: `--method=github` or `--method=cargo` ```bash $ devtrail update-cli -✔ CLI updated to cli-3.2.3 +✔ CLI updated to cli-3.2.4 $ devtrail update-cli --method=cargo Compiling from source, this may take a few minutes... -✔ CLI updated to cli-3.2.3 +✔ CLI updated to cli-3.2.4 ``` --- @@ -210,7 +210,7 @@ $ devtrail status ┌───────────┬──────────────────────────┐ │ Path │ /home/user/my-project │ │ Framework │ fw-4.2.0 │ - │ CLI │ cli-3.2.3 │ + │ CLI │ cli-3.2.4 │ │ Language │ en │ └───────────┴──────────────────────────┘ @@ -634,7 +634,7 @@ Show version, authorship, and license information. ```bash $ devtrail about DevTrail CLI - CLI version: cli-3.2.3 + CLI version: cli-3.2.4 Framework version: fw-4.2.0 Author: Strange Days Tech, S.A.S. License: MIT diff --git a/docs/i18n/es/README.md b/docs/i18n/es/README.md index b5226d5..769e27c 100644 --- a/docs/i18n/es/README.md +++ b/docs/i18n/es/README.md @@ -150,7 +150,7 @@ DevTrail usa tags de versión independientes para cada componente: | Componente | Prefijo de tag | Ejemplo | Incluye | |------------|---------------|---------|---------| | Framework | `fw-` | `fw-4.2.0` | Plantillas (12 tipos), gobernanza, directivas | -| CLI | `cli-` | `cli-3.2.3` | El binario `devtrail` | +| CLI | `cli-` | `cli-3.2.4` | El binario `devtrail` | Verifica las versiones instaladas con `devtrail status` o `devtrail about`. diff --git a/docs/i18n/es/adopters/CLI-REFERENCE.md b/docs/i18n/es/adopters/CLI-REFERENCE.md index cf2f831..8cf7911 100644 --- a/docs/i18n/es/adopters/CLI-REFERENCE.md +++ b/docs/i18n/es/adopters/CLI-REFERENCE.md @@ -49,7 +49,7 @@ DevTrail usa **tags de versión independientes** para cada componente: | Componente | Prefijo de tag | Ejemplo | Qué incluye | |------------|---------------|---------|-------------| | Framework | `fw-` | `fw-4.2.0` | Plantillas (12 tipos), docs de gobernanza, directivas | -| CLI | `cli-` | `cli-3.2.3` | El binario `devtrail` | +| CLI | `cli-` | `cli-3.2.4` | El binario `devtrail` | Framework y CLI se publican de forma independiente. Una actualización del framework no requiere actualización del CLI, y viceversa. @@ -109,7 +109,7 @@ $ devtrail update Updating framework... ✔ Framework updated to fw-4.2.0 Updating CLI... -✔ CLI updated to cli-3.2.3 +✔ CLI updated to cli-3.2.4 ``` --- @@ -142,11 +142,11 @@ Usa `--method` para forzar el método: `--method=github` o `--method=cargo`. ```bash $ devtrail update-cli -✔ CLI updated to cli-3.2.3 +✔ CLI updated to cli-3.2.4 $ devtrail update-cli --method=cargo Compiling from source, this may take a few minutes... -✔ CLI updated to cli-3.2.3 +✔ CLI updated to cli-3.2.4 ``` --- @@ -204,7 +204,7 @@ DevTrail Status ─────────────── Path: /home/user/my-project Framework version: fw-4.2.0 -CLI version: cli-3.2.3 +CLI version: cli-3.2.4 Language: en Structure: ✔ Complete @@ -513,7 +513,7 @@ Muestra información de versión, autoría y licencia. ```bash $ devtrail about DevTrail CLI - CLI version: cli-3.2.3 + CLI version: cli-3.2.4 Framework version: fw-4.2.0 Author: Strange Days Tech, S.A.S. License: MIT diff --git a/docs/i18n/zh-CN/README.md b/docs/i18n/zh-CN/README.md index 345bed3..378f32e 100644 --- a/docs/i18n/zh-CN/README.md +++ b/docs/i18n/zh-CN/README.md @@ -150,7 +150,7 @@ DevTrail 为每个组件使用独立的版本标签: | 组件 | 标签前缀 | 示例 | 包含内容 | |------|----------|------|----------| | Framework | `fw-` | `fw-4.2.0` | 模板(12 种类型)、治理文档、指令 | -| CLI | `cli-` | `cli-3.2.3` | `devtrail` 二进制文件 | +| CLI | `cli-` | `cli-3.2.4` | `devtrail` 二进制文件 | 使用 `devtrail status` 或 `devtrail about` 查看已安装的版本。 diff --git a/docs/i18n/zh-CN/adopters/CLI-REFERENCE.md b/docs/i18n/zh-CN/adopters/CLI-REFERENCE.md index b185170..4313d84 100644 --- a/docs/i18n/zh-CN/adopters/CLI-REFERENCE.md +++ b/docs/i18n/zh-CN/adopters/CLI-REFERENCE.md @@ -49,7 +49,7 @@ DevTrail 为每个组件使用**独立的版本标签**: | 组件 | 标签前缀 | 示例 | 包含内容 | |------|----------|------|----------| | Framework | `fw-` | `fw-4.2.0` | 模板(12 种类型)、治理文档、指令 | -| CLI | `cli-` | `cli-3.2.3` | `devtrail` 二进制文件 | +| CLI | `cli-` | `cli-3.2.4` | `devtrail` 二进制文件 | Framework 和 CLI 独立发布。Framework 更新不需要 CLI 更新,反之亦然。 @@ -110,7 +110,7 @@ $ devtrail update Updating framework... ✔ Framework updated to fw-4.2.0 Updating CLI... -✔ CLI updated to cli-3.2.3 +✔ CLI updated to cli-3.2.4 ``` --- @@ -143,11 +143,11 @@ $ devtrail update-framework ```bash $ devtrail update-cli -✔ CLI updated to cli-3.2.3 +✔ CLI updated to cli-3.2.4 $ devtrail update-cli --method=cargo Compiling from source, this may take a few minutes... -✔ CLI updated to cli-3.2.3 +✔ CLI updated to cli-3.2.4 ``` --- @@ -210,7 +210,7 @@ $ devtrail status ┌───────────┬──────────────────────────┐ │ Path │ /home/user/my-project │ │ Framework │ fw-4.2.0 │ - │ CLI │ cli-3.2.3 │ + │ CLI │ cli-3.2.4 │ │ Language │ en │ └───────────┴──────────────────────────┘ @@ -634,7 +634,7 @@ $ devtrail explore ```bash $ devtrail about DevTrail CLI - CLI version: cli-3.2.3 + CLI version: cli-3.2.4 Framework version: fw-4.2.0 Author: Strange Days Tech, S.A.S. License: MIT