use std::collections::HashMap;
use async_trait::async_trait;
use sha2::{Digest, Sha256};
use crate::options::OptionSchema;
use crate::payload::{
BadgeData, Bar, BarsData, Body, EntriesData, Entry, MarkdownTextBlockData, NumberSeriesData,
Payload, RatioData, Status, TextBlockData, TextData,
};
use crate::render::Shape;
use crate::samples;
use super::super::git::{open_repo, payload, repo_cache_key};
use super::super::{FetchContext, FetchError, Fetcher, Safety};
use super::scan::{classify_with_lang, for_each_tracked_file};
const SHAPES: &[Shape] = &[
Shape::Text,
Shape::TextBlock,
Shape::MarkdownTextBlock,
Shape::Entries,
Shape::Bars,
Shape::Ratio,
Shape::NumberSeries,
Shape::Badge,
];
const DEFAULT_LIMIT: usize = 10;
const OTHER_LABEL: &str = "Other";
const TIER_SMALL: u64 = 1_000;
const TIER_MEDIUM: u64 = 10_000;
const TIER_LARGE: u64 = 100_000;
const TIER_HUGE: u64 = 1_000_000;
const OPTION_SCHEMAS: &[OptionSchema] = &[
OptionSchema {
name: "limit",
type_hint: "integer",
required: false,
default: Some("10"),
description: "Cap on rendered languages (`TextBlock` / `MarkdownTextBlock` / `Entries` / `Bars` / `NumberSeries`). The `Text` summary always reports the full count.",
},
OptionSchema {
name: "unit",
type_hint: "`loc` | `kloc` | `percent` (alias `%`)",
required: false,
default: Some("loc"),
description: "Display format for counts: `loc` (`1,234`), `kloc` (`1.2k`), or `percent` (`67.8%`). Percent falls back to `loc` for the `Text` summary and `kloc` for `Badge` (where a percentage of self is meaningless). `Ratio` and `NumberSeries` ignore this option (Ratio is always 0..=1; NumberSeries renderers normalise visually).",
},
];
pub struct CodeLoc;
#[derive(Debug, Default, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Options {
#[serde(default)]
limit: Option<usize>,
#[serde(default)]
unit: Option<Unit>,
}
#[derive(Debug, Clone, Copy, Default, serde::Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
enum Unit {
#[default]
Loc,
Kloc,
#[serde(alias = "%")]
Percent,
}
#[async_trait]
impl Fetcher for CodeLoc {
fn name(&self) -> &str {
"code_loc"
}
fn safety(&self) -> Safety {
Safety::Safe
}
fn description(&self) -> &'static str {
"Counts lines per language across tracked source files in the discovered git repo (extension-based classification, vendored / generated dirs and lockfiles skipped). `Text` summarises totals; `TextBlock` / `MarkdownTextBlock` / `Entries` / `Bars` rank languages; `Ratio` exposes the primary language's share; `NumberSeries` sketches the distribution; `Badge` tiers the codebase by size. The `unit` option toggles raw / kloc / percent display."
}
fn shapes(&self) -> &[Shape] {
SHAPES
}
fn default_shape(&self) -> Shape {
Shape::Text
}
fn cache_key(&self, ctx: &FetchContext) -> String {
let base = repo_cache_key(self.name(), ctx);
let opts = ctx
.options
.as_ref()
.map(toml::Value::to_string)
.unwrap_or_default();
if opts.is_empty() {
return base;
}
let digest = Sha256::digest(opts.as_bytes());
let hex: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect();
format!("{base}-{hex}")
}
fn option_schemas(&self) -> &[OptionSchema] {
OPTION_SCHEMAS
}
fn sample_body(&self, shape: Shape) -> Option<Body> {
Some(match shape {
Shape::Text => samples::text("12,345 lines across 6 languages"),
Shape::TextBlock => samples::text_block(&[
"Rust 8,234",
"TypeScript 2,111",
"Markdown 820",
"TOML 560",
]),
Shape::MarkdownTextBlock => samples::markdown(
"- **Rust** 8,234\n- **TypeScript** 2,111\n- **Markdown** 820\n- **TOML** 560",
),
Shape::Entries => samples::entries(&[
("Rust", "8,234"),
("TypeScript", "2,111"),
("Markdown", "820"),
("TOML", "560"),
]),
Shape::Bars => samples::bars(&[
("Rust", 8234),
("TypeScript", 2111),
("Markdown", 820),
("TOML", 560),
]),
Shape::Ratio => samples::ratio(0.70, "Rust"),
Shape::NumberSeries => samples::number_series(&[8234, 2111, 820, 560, 234, 56]),
Shape::Badge => samples::badge(Status::Ok, "medium · 12k"),
_ => return None,
})
}
async fn fetch(&self, ctx: &FetchContext) -> Result<Payload, FetchError> {
let opts: Options = parse_options(ctx.options.as_ref())?;
let limit = opts.limit.filter(|n| *n > 0).unwrap_or(DEFAULT_LIMIT);
let unit = opts.unit.unwrap_or_default();
let totals = scan_cwd()?;
Ok(payload(render_body(
totals,
ctx.shape.unwrap_or(Shape::Text),
limit,
unit,
)))
}
}
fn parse_options(raw: Option<&toml::Value>) -> Result<Options, FetchError> {
match raw {
None => Ok(Options::default()),
Some(value) => value
.clone()
.try_into::<Options>()
.map_err(|e| FetchError::Failed(format!("invalid options: {e}"))),
}
}
#[derive(Debug, Default)]
struct Totals {
by_language: Vec<(String, u64)>,
total_lines: u64,
}
fn scan_cwd() -> Result<Totals, FetchError> {
let repo = open_repo()?;
scan_repo(&repo)
}
fn scan_repo(repo: &gix::Repository) -> Result<Totals, FetchError> {
let cfg = tokei::Config::default();
let mut counts: HashMap<&'static str, u64> = HashMap::new();
let mut other_lines: u64 = 0;
let mut total: u64 = 0;
for_each_tracked_file(repo, |path, bytes| match classify_with_lang(path, &cfg) {
Some((name, lang)) => {
let stats = lang.parse_from_slice(bytes, &cfg);
let lines = (stats.code + stats.comments + stats.blanks) as u64;
if lines == 0 {
return;
}
total += lines;
*counts.entry(name).or_insert(0) += lines;
}
None => {
let Ok(text) = std::str::from_utf8(bytes) else {
return;
};
let lines = text.lines().count() as u64;
if lines == 0 {
return;
}
total += lines;
other_lines += lines;
}
})?;
let mut by_language: Vec<(String, u64)> = counts
.into_iter()
.map(|(lang, n)| (lang.to_string(), n))
.collect();
if other_lines > 0 {
by_language.push((OTHER_LABEL.to_string(), other_lines));
}
by_language.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
Ok(Totals {
by_language,
total_lines: total,
})
}
fn render_body(totals: Totals, shape: Shape, limit: usize, unit: Unit) -> Body {
let total = totals.total_lines;
match shape {
Shape::TextBlock => Body::TextBlock(TextBlockData {
lines: totals
.by_language
.into_iter()
.take(limit)
.map(|(lang, n)| format!("{:<14} {}", lang, format_count(n, total, unit)))
.collect(),
}),
Shape::MarkdownTextBlock => render_markdown(totals, limit, unit),
Shape::Entries => Body::Entries(EntriesData {
items: totals
.by_language
.into_iter()
.take(limit)
.map(|(lang, n)| Entry {
key: lang,
value: Some(format_count(n, total, unit)),
status: None,
})
.collect(),
}),
Shape::Bars => Body::Bars(BarsData {
bars: totals
.by_language
.into_iter()
.take(limit)
.map(|(lang, n)| Bar {
label: lang,
value: n,
})
.collect(),
}),
Shape::Ratio => render_ratio(&totals),
Shape::NumberSeries => render_number_series(&totals, limit),
Shape::Badge => render_badge(total, unit),
_ => text_summary(total, totals.by_language.len(), unit),
}
}
fn render_markdown(totals: Totals, limit: usize, unit: Unit) -> Body {
let total = totals.total_lines;
let value = totals
.by_language
.into_iter()
.take(limit)
.map(|(lang, n)| format!("- **{lang}** {}", format_count(n, total, unit)))
.collect::<Vec<_>>()
.join("\n");
Body::MarkdownTextBlock(MarkdownTextBlockData { value })
}
fn render_ratio(totals: &Totals) -> Body {
let (label, value, denom) = match totals.by_language.first() {
Some((lang, n)) if totals.total_lines > 0 => {
let v = (*n as f64 / totals.total_lines as f64).clamp(0.0, 1.0);
(Some(lang.clone()), v, Some(totals.total_lines))
}
_ => (None, 0.0, None),
};
Body::Ratio(RatioData {
value,
label,
denominator: denom,
})
}
fn render_number_series(totals: &Totals, limit: usize) -> Body {
Body::NumberSeries(NumberSeriesData {
values: totals
.by_language
.iter()
.take(limit)
.map(|(_, n)| *n)
.collect(),
})
}
fn render_badge(total: u64, unit: Unit) -> Body {
if total == 0 {
return Body::Badge(BadgeData {
status: Status::Ok,
label: "empty".into(),
});
}
let (tier, status) = tier_for(total);
let display_unit = if unit == Unit::Percent {
Unit::Kloc
} else {
unit
};
Body::Badge(BadgeData {
status,
label: format!("{tier} · {}", format_count(total, total, display_unit)),
})
}
fn tier_for(total: u64) -> (&'static str, Status) {
if total < TIER_SMALL {
("toy", Status::Ok)
} else if total < TIER_MEDIUM {
("small", Status::Ok)
} else if total < TIER_LARGE {
("medium", Status::Ok)
} else if total < TIER_HUGE {
("large", Status::Warn)
} else {
("huge", Status::Error)
}
}
fn text_summary(total: u64, langs: usize, unit: Unit) -> Body {
let value = if total == 0 {
String::new()
} else {
let display_unit = if unit == Unit::Percent {
Unit::Loc
} else {
unit
};
let count_str = format_count(total, total, display_unit);
format!(
"{count_str} line{} across {langs} language{}",
if total == 1 { "" } else { "s" },
if langs == 1 { "" } else { "s" },
)
};
Body::Text(TextData { value })
}
fn format_count(n: u64, total: u64, unit: Unit) -> String {
match unit {
Unit::Loc => format_with_commas(n),
Unit::Kloc => format_kloc(n),
Unit::Percent => format_percent(n, total),
}
}
fn format_with_commas(n: u64) -> String {
let s = n.to_string();
let bytes = s.as_bytes();
let mut out = String::with_capacity(s.len() + s.len() / 3);
for (i, b) in bytes.iter().enumerate() {
if i > 0 && (bytes.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(*b as char);
}
out
}
fn format_kloc(n: u64) -> String {
if n < 1_000 {
n.to_string()
} else if n < 1_000_000 {
format!("{:.1}k", n as f64 / 1_000.0)
} else {
format!("{:.1}M", n as f64 / 1_000_000.0)
}
}
fn format_percent(n: u64, total: u64) -> String {
if total == 0 {
"0.0%".into()
} else {
format!("{:.1}%", (n as f64 / total as f64) * 100.0)
}
}
#[cfg(test)]
mod tests {
use std::process::Command;
use super::super::super::git::test_support::{commit_touching, make_repo};
use super::*;
fn ctx(shape: Option<Shape>, raw: Option<&str>) -> FetchContext {
FetchContext {
widget_id: "widget".into(),
shape,
options: raw.map(|value| toml::from_str(value).unwrap()),
..Default::default()
}
}
fn lang_map(totals: &Totals) -> HashMap<String, u64> {
totals.by_language.iter().cloned().collect()
}
fn totals(items: &[(&str, u64)]) -> Totals {
let total = items.iter().map(|(_, n)| n).sum();
Totals {
by_language: items.iter().map(|(l, n)| ((*l).into(), *n)).collect(),
total_lines: total,
}
}
#[test]
fn empty_repo_returns_empty_totals() {
let (_tmp, repo) = make_repo();
let totals = scan_repo(&repo).unwrap();
assert_eq!(totals.total_lines, 0);
assert!(totals.by_language.is_empty());
}
#[test]
fn counts_lines_per_language() {
let (_tmp, repo) = make_repo();
commit_touching(&repo, "src/main.rs", "fn main() {}\n// hi");
commit_touching(&repo, "doc.md", "# hi\n\ntext");
let totals = scan_repo(&repo).unwrap();
let langs = lang_map(&totals);
assert_eq!(langs.get("Rust"), Some(&2));
assert!(langs.get("Markdown").copied().unwrap_or(0) >= 3);
}
#[test]
fn unknown_extensions_bucket_into_other() {
let (_tmp, repo) = make_repo();
commit_touching(&repo, "weird.xyz", "a\nb\nc");
let totals = scan_repo(&repo).unwrap();
let langs = lang_map(&totals);
assert_eq!(langs.get("Other"), Some(&3));
}
#[test]
fn vendored_dirs_are_excluded() {
let (_tmp, repo) = make_repo();
commit_touching(&repo, "src/main.rs", "fn a() {}");
commit_touching(&repo, "node_modules/x.js", "var a = 1;");
commit_touching(&repo, "vendor/y.go", "package x");
let totals = scan_repo(&repo).unwrap();
let langs = lang_map(&totals);
assert!(langs.contains_key("Rust"));
assert!(!langs.contains_key("JavaScript"));
assert!(!langs.contains_key("Go"));
}
#[test]
fn lockfiles_are_excluded() {
let (_tmp, repo) = make_repo();
commit_touching(&repo, "src/main.rs", "fn main() {}");
commit_touching(&repo, "Cargo.lock", "noise\nnoise\nnoise");
commit_touching(&repo, "frontend/package-lock.json", "{}\n{}");
let totals = scan_repo(&repo).unwrap();
let langs = lang_map(&totals);
assert!(langs.contains_key("Rust"));
assert!(!langs.contains_key("Other"));
assert!(!langs.contains_key("JSON"));
}
#[test]
fn ranks_languages_by_line_count_descending() {
let (_tmp, repo) = make_repo();
commit_touching(&repo, "a.rs", "1\n2\n3\n4\n5");
commit_touching(&repo, "b.py", "1\n2");
let totals = scan_repo(&repo).unwrap();
let labels: Vec<_> = totals.by_language.iter().map(|(l, _)| l.clone()).collect();
assert_eq!(labels.first().map(String::as_str), Some("Rust"));
assert_eq!(labels.get(1).map(String::as_str), Some("Python"));
}
#[test]
fn fetcher_contract_and_samples_cover_supported_shapes() {
assert_eq!(CodeLoc.name(), "code_loc");
assert!(matches!(CodeLoc.safety(), Safety::Safe));
assert!(CodeLoc.description().contains("Counts lines per language"));
assert_eq!(CodeLoc.default_shape(), Shape::Text);
assert_eq!(CodeLoc.shapes(), SHAPES);
assert_eq!(
CodeLoc
.option_schemas()
.iter()
.map(|schema| schema.name)
.collect::<Vec<_>>(),
vec!["limit", "unit"]
);
SHAPES.iter().copied().for_each(|shape| {
let body = CodeLoc.sample_body(shape).expect("sample body");
assert_eq!(crate::render::shape_of(&body), shape);
});
assert!(CodeLoc.sample_body(Shape::Calendar).is_none());
}
#[test]
fn scan_repo_skips_empty_and_invalid_files_and_sorts_ties_by_name() {
let (_tmp, repo) = make_repo();
let dir = repo.workdir().expect("workdir");
[
("empty.rs", Vec::new()),
("blank.xyz", Vec::new()),
("bad.xyz", vec![0xff, 0xfe]),
("a.js", b"1\n2\n".to_vec()),
("b.py", b"1\n2\n".to_vec()),
]
.into_iter()
.for_each(|(path, bytes)| std::fs::write(dir.join(path), bytes).unwrap());
let add = Command::new("git")
.args(["add", "."])
.current_dir(dir)
.output()
.expect("git add");
assert!(
add.status.success(),
"git add failed: {}",
String::from_utf8_lossy(&add.stderr)
);
let commit = Command::new("git")
.args(["commit", "-q", "-m", "fixture"])
.current_dir(dir)
.output()
.expect("git commit");
assert!(
commit.status.success(),
"git commit failed: {}",
String::from_utf8_lossy(&commit.stderr)
);
let totals = scan_repo(&repo).unwrap();
assert_eq!(totals.total_lines, 4);
assert_eq!(
totals.by_language,
vec![("JavaScript".into(), 2), ("Python".into(), 2)]
);
}
#[test]
fn text_summary_reports_total_and_language_count() {
let body = render_body(
totals(&[("Rust", 1234), ("Python", 567)]),
Shape::Text,
10,
Unit::Loc,
);
match body {
Body::Text(d) => assert_eq!(d.value, "1,801 lines across 2 languages"),
_ => panic!(),
}
}
#[test]
fn text_summary_handles_singular_grammar() {
let body = render_body(totals(&[("Rust", 1)]), Shape::Text, 10, Unit::Loc);
match body {
Body::Text(d) => assert_eq!(d.value, "1 line across 1 language"),
_ => panic!(),
}
}
#[test]
fn text_summary_is_empty_when_no_lines() {
let body = render_body(Totals::default(), Shape::Text, 10, Unit::Loc);
match body {
Body::Text(d) => assert!(d.value.is_empty()),
_ => panic!(),
}
}
#[test]
fn text_summary_with_kloc_unit() {
let body = render_body(
totals(&[("Rust", 12_345), ("Python", 567)]),
Shape::Text,
10,
Unit::Kloc,
);
match body {
Body::Text(d) => assert_eq!(d.value, "12.9k lines across 2 languages"),
_ => panic!(),
}
}
#[test]
fn text_summary_falls_back_to_loc_for_percent_unit() {
let body = render_body(totals(&[("Rust", 1234)]), Shape::Text, 10, Unit::Percent);
match body {
Body::Text(d) => assert_eq!(d.value, "1,234 lines across 1 language"),
_ => panic!(),
}
}
#[test]
fn entries_carry_language_and_count() {
let body = render_body(totals(&[("Rust", 1234)]), Shape::Entries, 10, Unit::Loc);
match body {
Body::Entries(d) => {
assert_eq!(d.items[0].key, "Rust");
assert_eq!(d.items[0].value.as_deref(), Some("1,234"));
}
_ => panic!(),
}
}
#[test]
fn entries_with_percent_unit() {
let body = render_body(
totals(&[("Rust", 700), ("Python", 300)]),
Shape::Entries,
10,
Unit::Percent,
);
match body {
Body::Entries(d) => {
assert_eq!(d.items[0].value.as_deref(), Some("70.0%"));
assert_eq!(d.items[1].value.as_deref(), Some("30.0%"));
}
_ => panic!(),
}
}
#[test]
fn entries_with_kloc_unit() {
let body = render_body(
totals(&[("Rust", 8234), ("Python", 567)]),
Shape::Entries,
10,
Unit::Kloc,
);
match body {
Body::Entries(d) => {
assert_eq!(d.items[0].value.as_deref(), Some("8.2k"));
assert_eq!(d.items[1].value.as_deref(), Some("567"));
}
_ => panic!(),
}
}
#[test]
fn bars_carry_language_and_raw_count_regardless_of_unit() {
let body = render_body(
totals(&[("Rust", 1234), ("Python", 56)]),
Shape::Bars,
10,
Unit::Percent,
);
match body {
Body::Bars(d) => {
assert_eq!(d.bars[0].label, "Rust");
assert_eq!(d.bars[0].value, 1234);
}
_ => panic!(),
}
}
#[test]
fn text_block_uses_unit_for_count_column() {
let body = render_body(
totals(&[("Rust", 1234), ("Python", 56)]),
Shape::TextBlock,
10,
Unit::Kloc,
);
match body {
Body::TextBlock(d) => {
assert!(d.lines[0].contains("Rust"));
assert!(d.lines[0].contains("1.2k"));
assert!(d.lines[1].contains("56"));
}
_ => panic!(),
}
}
#[test]
fn markdown_text_block_emits_bold_list() {
let body = render_body(
totals(&[("Rust", 1234), ("Python", 56)]),
Shape::MarkdownTextBlock,
10,
Unit::Loc,
);
match body {
Body::MarkdownTextBlock(d) => {
assert_eq!(d.value, "- **Rust** 1,234\n- **Python** 56");
}
_ => panic!(),
}
}
#[test]
fn markdown_with_percent_unit() {
let body = render_body(
totals(&[("Rust", 700), ("Python", 300)]),
Shape::MarkdownTextBlock,
10,
Unit::Percent,
);
match body {
Body::MarkdownTextBlock(d) => {
assert_eq!(d.value, "- **Rust** 70.0%\n- **Python** 30.0%");
}
_ => panic!(),
}
}
#[test]
fn ratio_picks_primary_language_share() {
let body = render_body(
totals(&[("Rust", 700), ("Python", 300)]),
Shape::Ratio,
10,
Unit::Loc,
);
match body {
Body::Ratio(d) => {
assert!((d.value - 0.7).abs() < 1e-9);
assert_eq!(d.label.as_deref(), Some("Rust"));
assert_eq!(d.denominator, Some(1000));
}
_ => panic!(),
}
}
#[test]
fn ratio_handles_empty_totals() {
let body = render_body(Totals::default(), Shape::Ratio, 10, Unit::Loc);
match body {
Body::Ratio(d) => {
assert_eq!(d.value, 0.0);
assert!(d.label.is_none());
assert!(d.denominator.is_none());
}
_ => panic!(),
}
}
#[test]
fn number_series_emits_sorted_counts() {
let body = render_body(
totals(&[("Rust", 800), ("Python", 200), ("Go", 100)]),
Shape::NumberSeries,
10,
Unit::Loc,
);
match body {
Body::NumberSeries(d) => assert_eq!(d.values, vec![800, 200, 100]),
_ => panic!(),
}
}
#[test]
fn number_series_respects_limit() {
let body = render_body(
totals(&[("A", 5), ("B", 4), ("C", 3), ("D", 2)]),
Shape::NumberSeries,
2,
Unit::Loc,
);
match body {
Body::NumberSeries(d) => assert_eq!(d.values, vec![5, 4]),
_ => panic!(),
}
}
#[test]
fn badge_tiers_by_total_size() {
assert_eq!(tier_for(0), ("toy", Status::Ok));
assert_eq!(tier_for(500), ("toy", Status::Ok));
assert_eq!(tier_for(5_000), ("small", Status::Ok));
assert_eq!(tier_for(50_000), ("medium", Status::Ok));
assert_eq!(tier_for(500_000), ("large", Status::Warn));
assert_eq!(tier_for(5_000_000), ("huge", Status::Error));
}
#[test]
fn badge_label_includes_tier_and_count() {
let body = render_body(
totals(&[("Rust", 40_000), ("Python", 8_000)]),
Shape::Badge,
10,
Unit::Kloc,
);
match body {
Body::Badge(d) => {
assert_eq!(d.status, Status::Ok);
assert_eq!(d.label, "medium · 48.0k");
}
_ => panic!(),
}
}
#[test]
fn badge_falls_back_to_kloc_when_unit_is_percent() {
let body = render_body(totals(&[("Rust", 40_000)]), Shape::Badge, 10, Unit::Percent);
match body {
Body::Badge(d) => assert_eq!(d.label, "medium · 40.0k"),
_ => panic!(),
}
}
#[test]
fn badge_handles_empty() {
let body = render_body(Totals::default(), Shape::Badge, 10, Unit::Loc);
match body {
Body::Badge(d) => {
assert_eq!(d.status, Status::Ok);
assert_eq!(d.label, "empty");
}
_ => panic!(),
}
}
#[test]
fn limit_caps_rendered_rows() {
let body = render_body(
totals(&[("A", 5), ("B", 4), ("C", 3), ("D", 2), ("E", 1)]),
Shape::Entries,
2,
Unit::Loc,
);
match body {
Body::Entries(d) => assert_eq!(d.items.len(), 2),
_ => panic!(),
}
}
#[test]
fn format_with_commas_inserts_separators() {
assert_eq!(format_with_commas(0), "0");
assert_eq!(format_with_commas(123), "123");
assert_eq!(format_with_commas(1_234), "1,234");
assert_eq!(format_with_commas(1_234_567), "1,234,567");
}
#[test]
fn format_kloc_buckets_by_magnitude() {
assert_eq!(format_kloc(0), "0");
assert_eq!(format_kloc(999), "999");
assert_eq!(format_kloc(1_000), "1.0k");
assert_eq!(format_kloc(1_234), "1.2k");
assert_eq!(format_kloc(40_708), "40.7k");
assert_eq!(format_kloc(999_999), "1000.0k");
assert_eq!(format_kloc(1_500_000), "1.5M");
}
#[test]
fn format_percent_avoids_division_by_zero() {
assert_eq!(format_percent(0, 0), "0.0%");
assert_eq!(format_percent(50, 100), "50.0%");
assert_eq!(format_percent(1, 3), "33.3%");
}
#[test]
fn cache_key_changes_with_options() {
let _lock = crate::paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let mut ctx = FetchContext {
widget_id: "w".into(),
..Default::default()
};
let k0 = CodeLoc.cache_key(&ctx);
ctx.options = Some(toml::from_str(r#"limit = 5"#).unwrap());
let k1 = CodeLoc.cache_key(&ctx);
ctx.options = Some(toml::from_str(r#"unit = "kloc""#).unwrap());
let k2 = CodeLoc.cache_key(&ctx);
assert_ne!(k0, k1);
assert_ne!(k1, k2);
}
#[test]
fn cache_key_is_stable_for_equivalent_options() {
let _lock = crate::paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let mut ctx = FetchContext {
widget_id: "w".into(),
..Default::default()
};
ctx.options = Some(toml::from_str(r#"limit = 7"#).unwrap());
let a = CodeLoc.cache_key(&ctx);
ctx.options = Some(toml::from_str(r#"limit = 7"#).unwrap());
let b = CodeLoc.cache_key(&ctx);
assert_eq!(a, b);
}
#[test]
fn parse_options_rejects_unknown_keys() {
let bad: toml::Value = toml::from_str(r#"unknown = 1"#).unwrap();
assert!(parse_options(Some(&bad)).is_err());
}
#[test]
fn parse_options_accepts_unit_aliases() {
let kloc: toml::Value = toml::from_str(r#"unit = "kloc""#).unwrap();
assert_eq!(parse_options(Some(&kloc)).unwrap().unit, Some(Unit::Kloc));
let pct: toml::Value = toml::from_str(r#"unit = "percent""#).unwrap();
assert_eq!(parse_options(Some(&pct)).unwrap().unit, Some(Unit::Percent));
let pct_alias: toml::Value = toml::from_str(r#"unit = "%""#).unwrap();
assert_eq!(
parse_options(Some(&pct_alias)).unwrap().unit,
Some(Unit::Percent)
);
}
#[test]
fn parse_options_defaults_and_rejects_invalid_unit() {
let opts = parse_options(None).unwrap();
assert_eq!(opts.limit, None);
assert_eq!(opts.unit, None);
let bad: toml::Value = toml::from_str(r#"unit = "wat""#).unwrap();
let err = parse_options(Some(&bad)).unwrap_err();
assert!(matches!(err, FetchError::Failed(msg) if msg.contains("unknown variant")));
}
#[test]
fn fetch_reads_cwd_repo_for_default_and_requested_shapes() {
let _lock = crate::paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let (_tmp, repo) = crate::fetcher::git::test_support::make_repo();
crate::fetcher::git::test_support::commit_touching(&repo, "src/lib.rs", "fn hello() {}");
let workdir = repo.workdir().unwrap().to_path_buf();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let prev_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&workdir).unwrap();
let text = rt.block_on(CodeLoc.fetch(&ctx(None, None)));
let text_block = rt.block_on(CodeLoc.fetch(&ctx(
Some(Shape::TextBlock),
Some("limit = 0\nunit = \"percent\""),
)));
let expected_text =
scan_repo(&repo).map(|t| render_body(t, Shape::Text, DEFAULT_LIMIT, Unit::Loc));
let expected_block = scan_repo(&repo)
.map(|t| render_body(t, Shape::TextBlock, DEFAULT_LIMIT, Unit::Percent));
std::env::set_current_dir(prev_cwd).unwrap();
assert_eq!(text.unwrap().body, expected_text.unwrap());
assert_eq!(text_block.unwrap().body, expected_block.unwrap());
}
#[tokio::test]
async fn fetch_rejects_invalid_options() {
let err = CodeLoc
.fetch(&ctx(None, Some("unknown = 1")))
.await
.unwrap_err();
assert!(matches!(err, FetchError::Failed(msg) if msg.contains("invalid options")));
}
}