//! `code_largest_files` — ranks the top tracked files by line count or byte size. Headlines the
//! single biggest file in `Text`; multi-row shapes (`TextBlock` / `Entries` / `Bars` /
//! `MarkdownTextBlock` / `NumberSeries`) carry the per-file ranking. `Badge` flags whether the
//! repo is harbouring a refactor-candidate-sized file (separate health signal from `code_loc`'s
//! repo-total tier).
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, 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::for_each_tracked_file;
const SHAPES: &[Shape] = &[
Shape::Text,
Shape::TextBlock,
Shape::MarkdownTextBlock,
Shape::Entries,
Shape::Bars,
Shape::NumberSeries,
Shape::Badge,
];
const DEFAULT_LIMIT: usize = 10;
// Refactor-candidate tiers for `Badge`. Boundaries chosen for the LOC metric (single-file
// review effort): under ~200 lines is comfortable, ~500 starts to feel heavy, ~1000+ is the
// "split this up" zone. Bytes metric reuses the same tier names with KiB-scaled thresholds —
// the qualitative read ("is anything in this repo too big?") stays consistent.
const TIER_LOC_TIDY: u64 = 200;
const TIER_LOC_BIG: u64 = 500;
const TIER_LOC_BLOATED: u64 = 1_000;
const TIER_BYTES_TIDY: u64 = 16 * 1024;
const TIER_BYTES_BIG: u64 = 64 * 1024;
const TIER_BYTES_BLOATED: u64 = 256 * 1024;
const OPTION_SCHEMAS: &[OptionSchema] = &[
OptionSchema {
name: "metric",
type_hint: "`loc` | `bytes`",
required: false,
default: Some("loc"),
description: "What to rank files by: `loc` (text line count, binaries skipped) or `bytes` (raw file size). LOC suits refactor-candidate hunting; bytes suits asset-weight audits.",
},
OptionSchema {
name: "limit",
type_hint: "integer",
required: false,
default: Some("10"),
description: "Cap on ranked rows (`TextBlock` / `MarkdownTextBlock` / `Entries` / `Bars` / `NumberSeries`). The `Text` headline always names just the single largest file.",
},
];
/// Ranks the largest tracked source files in the discovered git repo. Walks the `gix` index
/// via the shared family helper, so untracked / `.gitignore`-d files plus committed-vendored
/// trees, lockfiles, and binaries are skipped automatically.
pub struct CodeLargestFiles;
#[derive(Debug, Default, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Options {
#[serde(default)]
metric: Option<Metric>,
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, Clone, Copy, Default, serde::Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
enum Metric {
#[default]
Loc,
Bytes,
}
#[async_trait]
impl Fetcher for CodeLargestFiles {
fn name(&self) -> &str {
"code_largest_files"
}
fn safety(&self) -> Safety {
Safety::Safe
}
fn description(&self) -> &'static str {
"Ranks the largest tracked source files in the discovered git repo. `metric = \"loc\"` (default) counts text lines per file; `metric = \"bytes\"` measures raw size. `Text` headlines the single biggest file; `TextBlock` / `MarkdownTextBlock` / `Entries` / `Bars` carry the per-file ranking; `NumberSeries` sketches the size distribution; `Badge` flags whether the repo holds a refactor-candidate-sized file (`tidy` / `big` / `bloated` / `monster`). Vendored / generated dirs, lockfiles, and binaries are skipped via the shared scan helper."
}
fn refresh_interval(&self) -> u64 {
60 * 10
}
fn shapes(&self) -> &[Shape] {
SHAPES
}
fn default_shape(&self) -> Shape {
Shape::Entries
}
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("src/render/mod.rs (1,234 LOC)"),
Shape::TextBlock => samples::text_block(&[
"src/render/mod.rs (1,234)",
"src/fetcher/git/mod.rs (812)",
"src/payload.rs (640)",
]),
Shape::MarkdownTextBlock => samples::markdown(
"- **src/render/mod.rs** — 1,234 LOC\n- **src/fetcher/git/mod.rs** — 812 LOC\n- **src/payload.rs** — 640 LOC",
),
Shape::Entries => samples::entries(&[
("src/render/mod.rs", "1,234"),
("src/fetcher/git/mod.rs", "812"),
("src/payload.rs", "640"),
]),
Shape::Bars => samples::bars(&[
("src/render/mod.rs", 1234),
("src/fetcher/git/mod.rs", 812),
("src/payload.rs", 640),
]),
Shape::NumberSeries => samples::number_series(&[1234, 812, 640, 420, 280]),
Shape::Badge => samples::badge(Status::Warn, "bloated · src/render/mod.rs (1,234)"),
_ => return None,
})
}
async fn fetch(&self, ctx: &FetchContext) -> Result<Payload, FetchError> {
let opts: Options = parse_options(ctx.options.as_ref())?;
let metric = opts.metric.unwrap_or_default();
let limit = opts.limit.filter(|n| *n > 0).unwrap_or(DEFAULT_LIMIT);
let scan = scan_cwd(metric)?;
Ok(payload(render_body(
scan,
ctx.shape.unwrap_or(Shape::Entries),
metric,
limit,
)))
}
}
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 Scan {
/// `(path, measure)` sorted by measure desc, then path asc.
by_file: Vec<(String, u64)>,
}
fn scan_cwd(metric: Metric) -> Result<Scan, FetchError> {
let repo = open_repo()?;
scan_repo(&repo, metric)
}
fn scan_repo(repo: &gix::Repository, metric: Metric) -> Result<Scan, FetchError> {
let mut by_file: Vec<(String, u64)> = Vec::new();
for_each_tracked_file(repo, |path, bytes| {
let measure = measure_file(metric, bytes);
if measure == 0 {
return;
}
by_file.push((path.to_string(), measure));
})?;
by_file.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
Ok(Scan { by_file })
}
fn measure_file(metric: Metric, bytes: &[u8]) -> u64 {
match metric {
Metric::Bytes => bytes.len() as u64,
Metric::Loc => match std::str::from_utf8(bytes) {
Ok(text) => text.lines().count() as u64,
Err(_) => 0,
},
}
}
fn render_body(scan: Scan, shape: Shape, metric: Metric, limit: usize) -> Body {
if scan.by_file.is_empty() {
return empty_body(shape);
}
match shape {
Shape::Text => text_body(&scan, metric),
Shape::TextBlock => text_block_body(&scan, limit),
Shape::MarkdownTextBlock => markdown_body(&scan, metric, limit),
Shape::Entries => entries_body(&scan, limit),
Shape::Bars => bars_body(scan, limit),
Shape::NumberSeries => number_series_body(&scan, limit),
Shape::Badge => badge_body(&scan, metric),
_ => text_body(&scan, metric),
}
}
fn empty_body(shape: Shape) -> Body {
match shape {
Shape::TextBlock => Body::TextBlock(TextBlockData { lines: vec![] }),
Shape::MarkdownTextBlock => Body::MarkdownTextBlock(MarkdownTextBlockData {
value: String::new(),
}),
Shape::Entries => Body::Entries(EntriesData { items: vec![] }),
Shape::Bars => Body::Bars(BarsData { bars: vec![] }),
Shape::NumberSeries => Body::NumberSeries(NumberSeriesData { values: vec![] }),
Shape::Badge => Body::Badge(BadgeData {
status: Status::Ok,
label: "empty".into(),
}),
_ => Body::Text(TextData {
value: String::new(),
}),
}
}
fn text_body(scan: &Scan, metric: Metric) -> Body {
let (path, measure) = scan.by_file.first().expect("non-empty scan");
Body::Text(TextData {
value: format!("{path} ({})", format_measure(*measure, metric)),
})
}
fn text_block_body(scan: &Scan, limit: usize) -> Body {
Body::TextBlock(TextBlockData {
lines: scan
.by_file
.iter()
.take(limit)
.map(|(path, n)| format!("{path} ({})", format_with_commas(*n)))
.collect(),
})
}
fn markdown_body(scan: &Scan, metric: Metric, limit: usize) -> Body {
Body::MarkdownTextBlock(MarkdownTextBlockData {
value: scan
.by_file
.iter()
.take(limit)
.map(|(path, n)| format!("- **{path}** — {}", format_measure(*n, metric)))
.collect::<Vec<_>>()
.join("\n"),
})
}
fn entries_body(scan: &Scan, limit: usize) -> Body {
Body::Entries(EntriesData {
items: scan
.by_file
.iter()
.take(limit)
.map(|(path, n)| Entry {
key: path.clone(),
value: Some(format_with_commas(*n)),
status: None,
})
.collect(),
})
}
fn bars_body(scan: Scan, limit: usize) -> Body {
Body::Bars(BarsData {
bars: scan
.by_file
.into_iter()
.take(limit)
.map(|(path, n)| Bar {
label: path,
value: n,
value_label: None,
})
.collect(),
})
}
fn number_series_body(scan: &Scan, limit: usize) -> Body {
Body::NumberSeries(NumberSeriesData {
values: scan.by_file.iter().take(limit).map(|(_, n)| *n).collect(),
})
}
fn badge_body(scan: &Scan, metric: Metric) -> Body {
let (path, measure) = scan.by_file.first().expect("non-empty scan");
let (tier, status) = tier_for(metric, *measure);
Body::Badge(BadgeData {
status,
label: format!("{tier} · {path} ({})", format_with_commas(*measure)),
})
}
fn tier_for(metric: Metric, measure: u64) -> (&'static str, Status) {
let (tidy, big, bloated) = match metric {
Metric::Loc => (TIER_LOC_TIDY, TIER_LOC_BIG, TIER_LOC_BLOATED),
Metric::Bytes => (TIER_BYTES_TIDY, TIER_BYTES_BIG, TIER_BYTES_BLOATED),
};
if measure < tidy {
("tidy", Status::Ok)
} else if measure < big {
("big", Status::Ok)
} else if measure < bloated {
("bloated", Status::Warn)
} else {
("monster", Status::Error)
}
}
fn format_measure(n: u64, metric: Metric) -> String {
let suffix = match metric {
Metric::Loc => "LOC",
Metric::Bytes => "bytes",
};
format!("{} {suffix}", format_with_commas(n))
}
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
}
#[cfg(test)]
mod tests {
use super::super::super::git::test_support::{commit_touching, make_repo};
use super::*;
fn ctx(shape: Option<Shape>) -> FetchContext {
FetchContext {
widget_id: "widget".into(),
shape,
..Default::default()
}
}
fn fixed_scan() -> Scan {
Scan {
by_file: vec![
("src/render/mod.rs".into(), 1_234),
("src/fetcher/git/mod.rs".into(), 812),
("src/payload.rs".into(), 640),
],
}
}
#[test]
fn fetcher_contract_and_samples_cover_supported_shapes() {
assert_eq!(CodeLargestFiles.name(), "code_largest_files");
assert!(matches!(CodeLargestFiles.safety(), Safety::Safe));
assert!(
CodeLargestFiles
.description()
.contains("largest tracked source files")
);
assert_eq!(CodeLargestFiles.default_shape(), Shape::Entries);
assert_eq!(CodeLargestFiles.shapes(), SHAPES);
assert_eq!(
CodeLargestFiles.option_schemas().len(),
OPTION_SCHEMAS.len()
);
SHAPES.iter().copied().for_each(|shape| {
let body = CodeLargestFiles.sample_body(shape).expect("sample body");
assert_eq!(crate::render::shape_of(&body), shape);
});
assert!(CodeLargestFiles.sample_body(Shape::Ratio).is_none());
}
#[test]
fn empty_repo_returns_empty_scan() {
let (_tmp, repo) = make_repo();
let scan = scan_repo(&repo, Metric::Loc).unwrap();
assert!(scan.by_file.is_empty());
}
#[test]
fn ranks_files_by_loc_descending() {
let (_tmp, repo) = make_repo();
commit_touching(&repo, "small.rs", "a");
commit_touching(&repo, "big.rs", "1\n2\n3\n4\n5");
commit_touching(&repo, "mid.rs", "x\ny\nz");
let scan = scan_repo(&repo, Metric::Loc).unwrap();
let names: Vec<_> = scan.by_file.iter().map(|(p, _)| p.clone()).collect();
assert_eq!(names, vec!["big.rs", "mid.rs", "small.rs"]);
}
#[test]
fn lockfiles_and_vendored_excluded() {
let (_tmp, repo) = make_repo();
commit_touching(&repo, "src/main.rs", "fn main() {}");
commit_touching(
&repo,
"Cargo.lock",
"noise\nnoise\nnoise\nnoise\nnoise\nnoise",
);
commit_touching(&repo, "node_modules/big.js", "x\ny\nz\nw\nq\nr");
let scan = scan_repo(&repo, Metric::Loc).unwrap();
let paths: Vec<_> = scan.by_file.iter().map(|(p, _)| p.as_str()).collect();
assert_eq!(paths, vec!["src/main.rs"]);
}
#[test]
fn bytes_metric_uses_raw_file_size() {
let (_tmp, repo) = make_repo();
// shorter content, more lines
commit_touching(&repo, "many.rs", "a\nb\nc\nd\ne");
// longer content, fewer lines
commit_touching(&repo, "long.rs", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
let scan = scan_repo(&repo, Metric::Bytes).unwrap();
let head = scan.by_file.first().map(|(p, _)| p.as_str());
assert_eq!(head, Some("long.rs"));
}
#[test]
fn text_headlines_largest_file() {
assert_eq!(
render_body(fixed_scan(), Shape::Text, Metric::Loc, 10),
Body::Text(TextData {
value: "src/render/mod.rs (1,234 LOC)".into(),
})
);
}
#[test]
fn text_uses_metric_suffix() {
assert_eq!(
render_body(fixed_scan(), Shape::Text, Metric::Bytes, 10),
Body::Text(TextData {
value: "src/render/mod.rs (1,234 bytes)".into(),
})
);
}
#[test]
fn text_is_empty_when_no_files() {
assert_eq!(
render_body(Scan::default(), Shape::Text, Metric::Loc, 10),
Body::Text(TextData {
value: String::new(),
})
);
}
#[test]
fn text_block_lists_ranked_files() {
assert_eq!(
render_body(fixed_scan(), Shape::TextBlock, Metric::Loc, 10),
Body::TextBlock(TextBlockData {
lines: vec![
"src/render/mod.rs (1,234)".into(),
"src/fetcher/git/mod.rs (812)".into(),
"src/payload.rs (640)".into(),
],
})
);
}
#[test]
fn entries_carry_path_and_count() {
assert_eq!(
render_body(fixed_scan(), Shape::Entries, Metric::Loc, 10),
Body::Entries(EntriesData {
items: vec![
Entry {
key: "src/render/mod.rs".into(),
value: Some("1,234".into()),
status: None,
},
Entry {
key: "src/fetcher/git/mod.rs".into(),
value: Some("812".into()),
status: None,
},
Entry {
key: "src/payload.rs".into(),
value: Some("640".into()),
status: None,
},
],
})
);
}
#[test]
fn bars_carry_path_and_raw_value() {
assert_eq!(
render_body(fixed_scan(), Shape::Bars, Metric::Loc, 10),
Body::Bars(BarsData {
bars: vec![
Bar {
label: "src/render/mod.rs".into(),
value: 1234,
value_label: None
},
Bar {
label: "src/fetcher/git/mod.rs".into(),
value: 812,
value_label: None
},
Bar {
label: "src/payload.rs".into(),
value: 640,
value_label: None
},
],
})
);
}
#[test]
fn markdown_emphasises_path_with_metric_suffix() {
assert_eq!(
render_body(fixed_scan(), Shape::MarkdownTextBlock, Metric::Loc, 10),
Body::MarkdownTextBlock(MarkdownTextBlockData {
value: [
"- **src/render/mod.rs** — 1,234 LOC",
"- **src/fetcher/git/mod.rs** — 812 LOC",
"- **src/payload.rs** — 640 LOC",
]
.join("\n"),
})
);
}
#[test]
fn number_series_emits_sorted_measures() {
assert_eq!(
render_body(fixed_scan(), Shape::NumberSeries, Metric::Loc, 10),
Body::NumberSeries(NumberSeriesData {
values: vec![1234, 812, 640],
})
);
}
#[test]
fn limit_caps_rendered_rows() {
let Body::Entries(d) = render_body(fixed_scan(), Shape::Entries, Metric::Loc, 2) else {
unreachable!();
};
assert_eq!(d.items.len(), 2);
}
#[test]
fn badge_tiers_by_largest_file_size_loc() {
assert_eq!(tier_for(Metric::Loc, 50), ("tidy", Status::Ok));
assert_eq!(tier_for(Metric::Loc, 300), ("big", Status::Ok));
assert_eq!(tier_for(Metric::Loc, 700), ("bloated", Status::Warn));
assert_eq!(tier_for(Metric::Loc, 5_000), ("monster", Status::Error));
}
#[test]
fn badge_tiers_by_largest_file_size_bytes() {
assert_eq!(tier_for(Metric::Bytes, 1024), ("tidy", Status::Ok));
assert_eq!(tier_for(Metric::Bytes, 32 * 1024), ("big", Status::Ok));
assert_eq!(
tier_for(Metric::Bytes, 100 * 1024),
("bloated", Status::Warn)
);
assert_eq!(
tier_for(Metric::Bytes, 500 * 1024),
("monster", Status::Error)
);
}
#[test]
fn badge_label_includes_tier_path_and_count() {
assert_eq!(
render_body(fixed_scan(), Shape::Badge, Metric::Loc, 10),
Body::Badge(BadgeData {
status: Status::Error,
label: "monster · src/render/mod.rs (1,234)".into(),
})
);
}
#[test]
fn badge_handles_empty() {
assert_eq!(
render_body(Scan::default(), Shape::Badge, Metric::Loc, 10),
Body::Badge(BadgeData {
status: Status::Ok,
label: "empty".into(),
})
);
}
#[test]
fn render_body_covers_empty_variants_and_text_fallback() {
assert_eq!(
render_body(Scan::default(), Shape::TextBlock, Metric::Loc, 10),
Body::TextBlock(TextBlockData { lines: vec![] })
);
assert_eq!(
render_body(Scan::default(), Shape::MarkdownTextBlock, Metric::Loc, 10),
Body::MarkdownTextBlock(MarkdownTextBlockData {
value: String::new(),
})
);
assert_eq!(
render_body(Scan::default(), Shape::Entries, Metric::Loc, 10),
Body::Entries(EntriesData { items: vec![] })
);
assert_eq!(
render_body(Scan::default(), Shape::Bars, Metric::Loc, 10),
Body::Bars(BarsData { bars: vec![] })
);
assert_eq!(
render_body(Scan::default(), Shape::NumberSeries, Metric::Loc, 10),
Body::NumberSeries(NumberSeriesData { values: vec![] })
);
assert_eq!(
render_body(fixed_scan(), Shape::Ratio, Metric::Bytes, 10),
Body::Text(TextData {
value: "src/render/mod.rs (1,234 bytes)".into(),
})
);
}
#[test]
fn cache_key_changes_with_options() {
let mut ctx = FetchContext {
widget_id: "w".into(),
..Default::default()
};
let k0 = CodeLargestFiles.cache_key(&ctx);
ctx.options = Some(toml::from_str(r#"limit = 5"#).unwrap());
let k1 = CodeLargestFiles.cache_key(&ctx);
ctx.options = Some(toml::from_str(r#"metric = "bytes""#).unwrap());
let k2 = CodeLargestFiles.cache_key(&ctx);
assert_ne!(k0, k1);
assert_ne!(k1, k2);
}
#[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_metric_variants() {
let loc: toml::Value = toml::from_str(r#"metric = "loc""#).unwrap();
assert_eq!(parse_options(Some(&loc)).unwrap().metric, Some(Metric::Loc));
let bytes: toml::Value = toml::from_str(r#"metric = "bytes""#).unwrap();
assert_eq!(
parse_options(Some(&bytes)).unwrap().metric,
Some(Metric::Bytes)
);
}
#[test]
fn parse_options_defaults_and_loc_metric_skips_invalid_utf8() {
let opts = parse_options(None).unwrap();
assert_eq!(opts.metric, None);
assert_eq!(opts.limit, None);
assert_eq!(measure_file(Metric::Loc, &[0xff, 0xfe]), 0);
}
#[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",
"pub fn hello() {}\n",
);
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 entries = rt.block_on(CodeLargestFiles.fetch(&ctx(None)));
let mut number_series_ctx = ctx(Some(Shape::NumberSeries));
number_series_ctx.options = Some(toml::from_str("metric = \"bytes\"\nlimit = 1").unwrap());
let number_series = rt.block_on(CodeLargestFiles.fetch(&number_series_ctx));
let mut default_limit_ctx = ctx(Some(Shape::NumberSeries));
default_limit_ctx.options = Some(toml::from_str("limit = 0").unwrap());
let default_limit = rt.block_on(CodeLargestFiles.fetch(&default_limit_ctx));
let expected_entries = scan_repo(&repo, Metric::Loc)
.map(|s| render_body(s, Shape::Entries, Metric::Loc, DEFAULT_LIMIT));
let expected_bytes = scan_repo(&repo, Metric::Bytes)
.map(|s| render_body(s, Shape::NumberSeries, Metric::Bytes, 1));
let expected_default_limit = scan_repo(&repo, Metric::Loc)
.map(|s| render_body(s, Shape::NumberSeries, Metric::Loc, DEFAULT_LIMIT));
std::env::set_current_dir(prev_cwd).unwrap();
assert_eq!(entries.unwrap().body, expected_entries.unwrap());
assert_eq!(number_series.unwrap().body, expected_bytes.unwrap());
assert_eq!(default_limit.unwrap().body, expected_default_limit.unwrap());
}
#[tokio::test]
async fn fetch_rejects_invalid_options() {
let mut bad = ctx(None);
bad.options = Some(toml::from_str("unknown = 1").unwrap());
let err = CodeLargestFiles.fetch(&bad).await.unwrap_err();
assert!(matches!(err, FetchError::Failed(msg) if msg.contains("invalid options")));
}
}