Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
2 changes: 1 addition & 1 deletion cli/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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]
Expand Down
50 changes: 30 additions & 20 deletions cli/src/commands/analyze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>, output: &str, top: Option<usize>) -> Result<()> {
let target = PathBuf::from(path)
Expand Down Expand Up @@ -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(),
Expand All @@ -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,
Expand Down Expand Up @@ -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!("{:<width$}", s, width = max)
} else {
let truncated = &s[s.len() - (max - 2)..];
format!("..{:<width$}", truncated, width = max - 2)
if max == 0 {
return String::new();
}
}

/// Truncate a string to fit within a given width
fn truncate_str(s: &str, max: usize) -> String {
if s.len() <= max {
format!("{:<width$}", s, width = max)
} else {
format!("{:.width$}", s, width = max - 2)
if visual_width(s) <= max {
return pad_right_visual(s, max);
}
// Walk the string from the end, accumulating chars until we've consumed
// `max - 1` columns (leaving 1 column for the leading "…").
let budget = max.saturating_sub(1);
let mut used = 0usize;
let mut keep_from = s.len();
for (byte_idx, ch) in s.char_indices().rev() {
let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if used + w > 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)
}
15 changes: 11 additions & 4 deletions cli/src/commands/new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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::<u32>() {
// 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::<u32>() {
max_seq = max_seq.max(n);
}
}
Expand Down
73 changes: 35 additions & 38 deletions cli/src/commands/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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] = &[
Expand Down Expand Up @@ -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!(
" │ {:<label_w$} │ {:<value_w$} │",
format!("{label}").dimmed(),
value
" │ {} │ {} │",
pad_right_visual(label, label_w).dimmed(),
pad_right_visual(value, value_w),
);
}
print_border(" └", label_w, "┴", value_w, "┘");
Expand Down Expand Up @@ -137,21 +145,21 @@ pub fn run(path: &str) -> 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!(
" {:<name_w$} {} {:<status_w$}",
"Directory / File".dimmed(),
" {} {} {}",
pad_right_visual("Directory / File", name_w).dimmed(),
"│".dimmed(),
"Status".dimmed()
pad_right_visual("Status", status_w).dimmed(),
);
println!(
" {}",
Expand All @@ -160,16 +168,12 @@ pub fn run(path: &str) -> Result<()> {

for (name, exists) in &struct_items {
let status_text = if *exists { "✓ OK" } else { "✗ --" };
let plain_row = format!(" {:<name_w$} │ {:<status_w$}", name, status_text);
let name_cell = pad_right_visual(name, name_w);
let status_cell = pad_right_visual(status_text, status_w);
if *exists {
println!("{}", plain_row.replace(status_text, &status_text.green().to_string()));
println!(" {} │ {}", name_cell, status_cell.green());
} else {
println!(
"{}",
plain_row
.replace(name.as_str(), &name.yellow().to_string())
.replace(status_text, &status_text.yellow().to_string())
);
println!(" {} │ {}", name_cell.yellow(), status_cell.yellow());
}
}

Expand All @@ -182,18 +186,18 @@ pub fn run(path: &str) -> 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!(
" {:<type_w$} {} {:<count_w$}",
"Type".dimmed(),
" {} {} {}",
pad_right_visual("Type", type_w).dimmed(),
"│".dimmed(),
"Count".dimmed()
pad_right_visual("Count", count_w).dimmed(),
);
println!(
" {}",
Expand All @@ -203,26 +207,19 @@ pub fn run(path: &str) -> 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!(
" {:<type_w$} │ {}",
display,
count_str.green().bold()
);
println!(" {} │ {}", padded, count_str.green().bold());
} else {
println!(
" {} │ {}",
format!("{:<type_w$}", display).dimmed(),
count_str.dimmed()
);
println!(" {} │ {}", padded.dimmed(), count_str.dimmed());
}
}

let total_str = format!("{total:>count_w$}");
println!(
" {:<type_w$} │ {}",
"TOTAL".bold(),
total_str.cyan().bold()
" {} │ {}",
pad_right_visual("TOTAL", type_w).bold(),
total_str.cyan().bold(),
);
println!();

Expand Down
10 changes: 6 additions & 4 deletions cli/src/tui/markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,18 +153,20 @@ pub fn markdown_to_lines(markdown: &str, available_width: usize) -> Vec<Line<'st
}
TagEnd::CodeBlock => {
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()
.fg(Color::Rgb(210, 215, 235))
.bg(Color::Rgb(45, 45, 60));

for code_line in &code_block_lines {
let padded = format!(" {:<width$} ", code_line, width = max_len);
let w = UnicodeWidthStr::width(code_line.as_str());
let pad = max_cols.saturating_sub(w);
let padded = format!(" {}{} ", code_line, " ".repeat(pad));
let mut spans: Vec<Span<'static>> = Vec::new();
if content_indent > 0 {
spans.push(Span::raw(" ".repeat(content_indent)));
Expand Down
Loading