use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anyhow::Context;
use crate::utils::IconEntry;
pub const DEFAULT_FLUTTER_FOLDER: &str = "assets/icons";
pub const DEFAULT_FLUTTER_BARREL_FILE: &str = "lib/icons.dart";
pub const DEFAULT_FLUTTER_BARREL_CLASS: &str = "AppIcons";
const GENERATED_HEADER: &str = "// GENERATED by iconmate — do not edit by hand.";
#[derive(Debug, Clone, PartialEq)]
pub struct DartBarrelEntry {
pub identifier: String,
pub asset_path: String,
}
#[derive(Debug, Clone)]
pub struct FlutterProject {
pub root: PathBuf,
pub package_name: Option<String>,
}
const DART_RESERVED_WORDS: &[&str] = &[
"abstract",
"as",
"assert",
"break",
"case",
"catch",
"class",
"const",
"continue",
"default",
"deferred",
"do",
"dynamic",
"else",
"enum",
"export",
"extends",
"extension",
"external",
"factory",
"false",
"final",
"finally",
"for",
"Function",
"get",
"hide",
"if",
"implements",
"import",
"in",
"interface",
"is",
"late",
"library",
"mixin",
"new",
"null",
"of",
"on",
"operator",
"part",
"required",
"rethrow",
"return",
"sealed",
"set",
"show",
"static",
"super",
"switch",
"sync",
"this",
"throw",
"true",
"try",
"typedef",
"var",
"void",
"when",
"while",
"with",
"yield",
];
pub fn sanitize_identifier(raw: &str) -> anyhow::Result<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
anyhow::bail!("Identifier cannot be empty.");
}
let parts: Vec<String> = trimmed
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|part| !part.is_empty())
.flat_map(split_case_boundaries)
.collect();
if parts.is_empty() {
anyhow::bail!(
"Could not derive a Dart identifier from '{}': no alphanumeric characters.",
raw
);
}
let mut out = String::new();
for (i, part) in parts.iter().enumerate() {
if i == 0 {
out.push_str(&part.to_ascii_lowercase());
} else {
let mut chars = part.chars();
if let Some(first) = chars.next() {
out.extend(first.to_ascii_uppercase().to_string().chars());
out.push_str(&chars.as_str().to_ascii_lowercase());
}
}
}
if out.is_empty() {
anyhow::bail!("Could not derive a Dart identifier from '{}'.", raw);
}
if out.chars().next().unwrap().is_ascii_digit() {
out = format!("icon{}", capitalize_first(&out));
}
if DART_RESERVED_WORDS.contains(&out.as_str()) {
out.push_str("Icon");
}
Ok(out)
}
fn split_case_boundaries(chunk: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut current = String::new();
let mut prev: Option<char> = None;
for ch in chunk.chars() {
if let Some(p) = prev {
if p.is_ascii_lowercase() && ch.is_ascii_uppercase()
|| p.is_ascii_digit() && ch.is_ascii_uppercase()
{
if !current.is_empty() {
parts.push(std::mem::take(&mut current));
}
}
}
current.push(ch);
prev = Some(ch);
}
if !current.is_empty() {
parts.push(current);
}
parts
}
fn capitalize_first(value: &str) -> String {
let mut chars = value.chars();
match chars.next() {
Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
None => String::new(),
}
}
pub fn parse_dart_barrel(contents: &str) -> Vec<DartBarrelEntry> {
let mut entries = Vec::new();
for line in contents.lines() {
if let Some(entry) = parse_dart_barrel_line(line) {
entries.push(entry);
}
}
entries
}
fn parse_dart_barrel_line(line: &str) -> Option<DartBarrelEntry> {
let trimmed = line.trim();
if !trimmed.starts_with("static const") {
return None;
}
let rest = trimmed.trim_start_matches("static const").trim_start();
let rest = rest
.strip_prefix("String")
.map(|s| s.trim_start())
.unwrap_or(rest);
let eq_pos = rest.find('=')?;
let identifier = rest[..eq_pos].trim().to_string();
if identifier.is_empty() {
return None;
}
let after_eq = rest[eq_pos + 1..].trim_start();
let quote = after_eq.chars().next()?;
if quote != '\'' && quote != '"' {
return None;
}
let path_start = quote.len_utf8();
let path_end = after_eq[path_start..].find(quote)?;
let asset_path = after_eq[path_start..path_start + path_end].to_string();
if asset_path.is_empty() {
return None;
}
Some(DartBarrelEntry {
identifier,
asset_path,
})
}
pub fn render_dart_barrel(class_name: &str, entries: &[DartBarrelEntry]) -> String {
let mut out = String::new();
out.push_str(GENERATED_HEADER);
out.push('\n');
out.push_str(&format!("class {class_name} {{\n"));
out.push_str(&format!(" {class_name}._();\n"));
if !entries.is_empty() {
out.push('\n');
}
for entry in entries {
out.push_str(&format!(
" static const String {} = '{}';\n",
entry.identifier, entry.asset_path
));
}
out.push_str("}\n");
out
}
pub fn read_barrel_entries(barrel_path: &Path) -> anyhow::Result<Vec<DartBarrelEntry>> {
if !barrel_path.exists() {
return Ok(Vec::new());
}
let contents = std::fs::read_to_string(barrel_path)
.with_context(|| format!("Failed to read barrel file {}", barrel_path.display()))?;
Ok(parse_dart_barrel(&contents))
}
pub fn write_barrel(
barrel_path: &Path,
class_name: &str,
entries: &[DartBarrelEntry],
) -> anyhow::Result<()> {
if let Some(parent) = barrel_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).with_context(|| {
format!("Failed to create barrel parent dir {}", parent.display())
})?;
}
}
let rendered = render_dart_barrel(class_name, entries);
std::fs::write(barrel_path, rendered)
.with_context(|| format!("Failed to write barrel file {}", barrel_path.display()))?;
Ok(())
}
pub fn asset_path_for(folder: &str, file_name: &str) -> String {
let folder = folder.trim_end_matches('/').replace('\\', "/");
let file_name = file_name.trim_start_matches('/').replace('\\', "/");
if folder.is_empty() {
file_name
} else {
format!("{}/{}", folder, file_name)
}
}
pub fn add_entry(
existing: &[DartBarrelEntry],
identifier: &str,
asset_path: &str,
) -> anyhow::Result<Vec<DartBarrelEntry>> {
if existing.iter().any(|entry| entry.identifier == identifier) {
anyhow::bail!(
"Dart identifier '{}' already exists in the barrel. Choose a different --name.",
identifier
);
}
if existing.iter().any(|entry| entry.asset_path == asset_path) {
anyhow::bail!(
"Asset path '{}' is already referenced in the barrel. Choose a different filename.",
asset_path
);
}
let mut out = existing.to_vec();
out.push(DartBarrelEntry {
identifier: identifier.to_string(),
asset_path: asset_path.to_string(),
});
Ok(out)
}
pub fn remove_entry_by_path(
existing: &[DartBarrelEntry],
asset_path: &str,
) -> (Vec<DartBarrelEntry>, bool) {
let mut removed = false;
let out = existing
.iter()
.cloned()
.filter(|entry| {
if !removed && entry.asset_path == asset_path {
removed = true;
false
} else {
true
}
})
.collect();
(out, removed)
}
pub fn rename_entry_path(
existing: &[DartBarrelEntry],
current_asset_path: &str,
new_asset_path: &str,
) -> anyhow::Result<Vec<DartBarrelEntry>> {
if current_asset_path == new_asset_path {
return Ok(existing.to_vec());
}
if existing
.iter()
.any(|entry| entry.asset_path == new_asset_path)
{
anyhow::bail!(
"Asset path '{}' is already referenced in the barrel.",
new_asset_path
);
}
let mut matched = false;
let out: Vec<DartBarrelEntry> = existing
.iter()
.cloned()
.map(|entry| {
if !matched && entry.asset_path == current_asset_path {
matched = true;
DartBarrelEntry {
identifier: entry.identifier,
asset_path: new_asset_path.to_string(),
}
} else {
entry
}
})
.collect();
if !matched {
anyhow::bail!(
"No barrel entry references the asset '{}'.",
current_asset_path
);
}
Ok(out)
}
pub fn detect_flutter_project(start_dir: &Path) -> Option<FlutterProject> {
let mut current = Some(start_dir.to_path_buf());
while let Some(dir) = current {
let pubspec = dir.join("pubspec.yaml");
if pubspec.exists() {
if let Ok(contents) = std::fs::read_to_string(&pubspec) {
if pubspec_has_flutter_section(&contents) {
return Some(FlutterProject {
root: dir,
package_name: parse_pubspec_name(&contents),
});
}
}
}
current = dir.parent().map(|p| p.to_path_buf());
}
None
}
fn pubspec_has_flutter_section(contents: &str) -> bool {
for line in contents.lines() {
if line.starts_with("flutter:") {
return true;
}
}
false
}
fn parse_pubspec_name(contents: &str) -> Option<String> {
for line in contents.lines() {
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix("name:") {
let value = rest.trim().trim_matches(|c: char| c == '"' || c == '\'');
if !value.is_empty() {
return Some(value.to_string());
}
}
}
None
}
pub fn barrel_entries_to_icon_entries(entries: &[DartBarrelEntry], folder: &str) -> Vec<IconEntry> {
let folder_norm = folder.trim_end_matches('/').replace('\\', "/");
entries
.iter()
.map(|entry| {
let asset_norm = entry.asset_path.replace('\\', "/");
let file_path =
if !folder_norm.is_empty() && asset_norm.starts_with(&format!("{folder_norm}/")) {
asset_norm[folder_norm.len() + 1..].to_string()
} else {
asset_norm
};
IconEntry {
name: entry.identifier.clone(),
file_path,
}
})
.collect()
}
pub fn resolve_unique_identifier(
existing: &[DartBarrelEntry],
primary: &str,
fallback: Option<&str>,
) -> anyhow::Result<String> {
let primary_id = sanitize_identifier(primary)?;
let taken: HashSet<&str> = existing.iter().map(|e| e.identifier.as_str()).collect();
if !taken.contains(primary_id.as_str()) {
return Ok(primary_id);
}
if let Some(fb) = fallback {
let fallback_id = sanitize_identifier(fb)?;
if !taken.contains(fallback_id.as_str()) {
return Ok(fallback_id);
}
anyhow::bail!(
"Identifier '{}' and fallback '{}' both already exist in the barrel. Pass --name explicitly to resolve.",
primary_id,
fallback_id
);
}
anyhow::bail!(
"Identifier '{}' already exists in the barrel. Pass --name explicitly to resolve.",
primary_id
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_snake_case_to_lower_camel() {
assert_eq!(
sanitize_identifier("chevron_right").unwrap(),
"chevronRight"
);
}
#[test]
fn sanitize_kebab_case_to_lower_camel() {
assert_eq!(sanitize_identifier("arrow-left").unwrap(), "arrowLeft");
}
#[test]
fn sanitize_pascal_normalized_to_lower_camel() {
assert_eq!(sanitize_identifier("Heart").unwrap(), "heart");
assert_eq!(sanitize_identifier("ChevronRight").unwrap(), "chevronRight");
assert_eq!(sanitize_identifier("mdiHeart").unwrap(), "mdiHeart");
}
#[test]
fn sanitize_digit_leading_gets_icon_prefix() {
assert_eq!(sanitize_identifier("24_hours").unwrap(), "icon24Hours");
}
#[test]
fn sanitize_reserved_word_gets_icon_suffix() {
assert_eq!(sanitize_identifier("class").unwrap(), "classIcon");
assert_eq!(sanitize_identifier("new").unwrap(), "newIcon");
}
#[test]
fn sanitize_errors_on_empty_input() {
assert!(sanitize_identifier("").is_err());
assert!(sanitize_identifier(" ").is_err());
assert!(sanitize_identifier("___").is_err());
}
#[test]
fn parse_simple_barrel() {
let contents = "// GENERATED by iconmate — do not edit by hand.\nclass AppIcons {\n AppIcons._();\n\n static const String heart = 'assets/icons/heart.svg';\n static const String chevronRight = 'assets/icons/chevron_right.svg';\n}\n";
let entries = parse_dart_barrel(contents);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].identifier, "heart");
assert_eq!(entries[0].asset_path, "assets/icons/heart.svg");
assert_eq!(entries[1].identifier, "chevronRight");
assert_eq!(entries[1].asset_path, "assets/icons/chevron_right.svg");
}
#[test]
fn parse_barrel_without_type_annotation() {
let contents = "class AppIcons {\n static const heart = 'assets/icons/heart.svg';\n}\n";
let entries = parse_dart_barrel(contents);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].identifier, "heart");
}
#[test]
fn parse_barrel_with_double_quotes() {
let contents = " static const String heart = \"assets/icons/heart.svg\";\n";
let entries = parse_dart_barrel(contents);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].asset_path, "assets/icons/heart.svg");
}
#[test]
fn render_roundtrips_parse() {
let entries = vec![
DartBarrelEntry {
identifier: "heart".to_string(),
asset_path: "assets/icons/heart.svg".to_string(),
},
DartBarrelEntry {
identifier: "chevronRight".to_string(),
asset_path: "assets/icons/chevron_right.svg".to_string(),
},
];
let rendered = render_dart_barrel("AppIcons", &entries);
assert!(rendered.contains("// GENERATED by iconmate"));
assert!(rendered.contains("class AppIcons {"));
assert!(rendered.contains("AppIcons._();"));
assert!(rendered.contains("static const String heart = 'assets/icons/heart.svg';"));
let reparsed = parse_dart_barrel(&rendered);
assert_eq!(reparsed, entries);
}
#[test]
fn render_empty_barrel_still_has_class() {
let rendered = render_dart_barrel("AppIcons", &[]);
assert!(rendered.contains("class AppIcons {"));
assert!(rendered.contains("AppIcons._();"));
}
#[test]
fn add_entry_errors_on_duplicate_identifier() {
let existing = vec![DartBarrelEntry {
identifier: "heart".to_string(),
asset_path: "assets/icons/heart.svg".to_string(),
}];
let err = add_entry(&existing, "heart", "assets/icons/other.svg").unwrap_err();
assert!(err.to_string().contains("already exists"));
}
#[test]
fn add_entry_errors_on_duplicate_asset_path() {
let existing = vec![DartBarrelEntry {
identifier: "heart".to_string(),
asset_path: "assets/icons/heart.svg".to_string(),
}];
let err = add_entry(&existing, "redHeart", "assets/icons/heart.svg").unwrap_err();
assert!(err.to_string().contains("already referenced"));
}
#[test]
fn remove_entry_by_path_works() {
let existing = vec![
DartBarrelEntry {
identifier: "heart".to_string(),
asset_path: "assets/icons/heart.svg".to_string(),
},
DartBarrelEntry {
identifier: "star".to_string(),
asset_path: "assets/icons/star.svg".to_string(),
},
];
let (updated, removed) = remove_entry_by_path(&existing, "assets/icons/heart.svg");
assert!(removed);
assert_eq!(updated.len(), 1);
assert_eq!(updated[0].identifier, "star");
}
#[test]
fn rename_entry_path_preserves_identifier() {
let existing = vec![DartBarrelEntry {
identifier: "heart".to_string(),
asset_path: "assets/icons/heart.svg".to_string(),
}];
let updated = rename_entry_path(
&existing,
"assets/icons/heart.svg",
"assets/icons/redheart.svg",
)
.unwrap();
assert_eq!(updated.len(), 1);
assert_eq!(updated[0].identifier, "heart");
assert_eq!(updated[0].asset_path, "assets/icons/redheart.svg");
}
#[test]
fn rename_entry_errors_when_target_exists() {
let existing = vec![
DartBarrelEntry {
identifier: "heart".to_string(),
asset_path: "assets/icons/heart.svg".to_string(),
},
DartBarrelEntry {
identifier: "star".to_string(),
asset_path: "assets/icons/star.svg".to_string(),
},
];
assert!(
rename_entry_path(&existing, "assets/icons/heart.svg", "assets/icons/star.svg")
.is_err()
);
}
#[test]
fn asset_path_joins_correctly() {
assert_eq!(
asset_path_for("assets/icons", "heart.svg"),
"assets/icons/heart.svg"
);
assert_eq!(
asset_path_for("assets/icons/", "heart.svg"),
"assets/icons/heart.svg"
);
assert_eq!(asset_path_for("", "heart.svg"), "heart.svg");
}
#[test]
fn resolve_unique_identifier_uses_fallback_on_collision() {
let existing = vec![DartBarrelEntry {
identifier: "heart".to_string(),
asset_path: "assets/icons/heart.svg".to_string(),
}];
let id = resolve_unique_identifier(&existing, "heart", Some("mdi:heart")).unwrap();
assert_eq!(id, "mdiHeart");
}
#[test]
fn resolve_unique_identifier_errors_when_both_taken() {
let existing = vec![
DartBarrelEntry {
identifier: "heart".to_string(),
asset_path: "assets/icons/heart.svg".to_string(),
},
DartBarrelEntry {
identifier: "mdiHeart".to_string(),
asset_path: "assets/icons/mdi_heart.svg".to_string(),
},
];
assert!(resolve_unique_identifier(&existing, "heart", Some("mdi:heart")).is_err());
}
#[test]
fn detect_pubspec_name_and_flutter_section() {
let contents = "name: my_app\nversion: 0.1.0\n\ndependencies:\n flutter:\n sdk: flutter\n\nflutter:\n uses-material-design: true\n";
assert!(pubspec_has_flutter_section(contents));
assert_eq!(parse_pubspec_name(contents), Some("my_app".to_string()));
}
#[test]
fn pubspec_without_top_level_flutter_returns_none() {
let contents = "name: pure_dart\nversion: 0.1.0\ndependencies:\n http: ^1.0.0\n";
assert!(!pubspec_has_flutter_section(contents));
}
#[test]
fn barrel_entries_to_icon_entries_strips_folder_prefix() {
let entries = vec![DartBarrelEntry {
identifier: "heart".to_string(),
asset_path: "assets/icons/heart.svg".to_string(),
}];
let icons = barrel_entries_to_icon_entries(&entries, "assets/icons");
assert_eq!(icons.len(), 1);
assert_eq!(icons[0].name, "heart");
assert_eq!(icons[0].file_path, "heart.svg");
}
}