Skip to content

Commit f1e383b

Browse files
committed
feat: add file encoding conversion command and service
- Implemented `file convert-encoding` command for converting text files between different encodings. - Added support for automatic encoding detection using UTF.Unknown library. - Included options for batch processing with glob patterns and backup creation. - Enhanced file handling with text-only filtering and output directory specification. - Created unit tests for `EncodingConversionService` covering various scenarios. - Updated documentation to reflect new features and usage instructions.
1 parent 20a372a commit f1e383b

27 files changed

Lines changed: 1916 additions & 15 deletions

CLAUDE.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,12 @@ FurLab/
4444
│ ├── Infrastructure/ ← TypeRegistrar and TypeResolver for Spectre.Console.Cli
4545
│ ├── Commands/ ← Command classes - each subcommand has its own folder
4646
│ │ ├── Files/
47-
│ │ │ └── Combine/
48-
│ │ │ ├── CombineCommand.cs
49-
│ │ │ └── CombineSettings.cs
47+
│ │ │ ├── Combine/
48+
│ │ │ │ ├── FileCombineCommand.cs
49+
│ │ │ │ └── FileCombineSettings.cs
50+
│ │ │ └── ConvertEncoding/
51+
│ │ │ ├── FilesConvertEncodingCommand.cs
52+
│ │ │ └── FilesConvertEncodingSettings.cs
5053
│ │ ├── Claude/
5154
│ │ │ ├── Install/
5255
│ │ │ │ ├── InstallCommand.cs
@@ -225,6 +228,7 @@ FurLab database pgpass list
225228
FurLab database pgpass remove <banco> [--host] [--port] [--username]
226229
FurLab docker postgres
227230
FurLab file combine -i "<glob>" -o <output>
231+
FurLab file convert-encoding -i "<glob>" --to <encoding> [--from <encoding>] [--backup] [--text-only]
228232
FurLab query run -f <sql-file> -d <db> [--all] [--servers]
229233
FurLab winget backup -o <dir>
230234
FurLab winget restore -i <json-file>
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
using FurLab.Core.Interfaces;
2+
using FurLab.Core.Models;
3+
4+
using Spectre.Console;
5+
using Spectre.Console.Cli;
6+
7+
namespace FurLab.CLI.Commands.Files.ConvertEncoding;
8+
9+
/// <summary>
10+
/// Converts files from one encoding to another.
11+
/// </summary>
12+
/// <remarks>
13+
/// Initializes a new instance of the <see cref="FilesConvertEncodingCommand"/> class.
14+
/// </remarks>
15+
/// <param name="conversionService">The encoding conversion service.</param>
16+
public sealed class FilesConvertEncodingCommand(IEncodingConversionService conversionService) : AsyncCommand<FilesConvertEncodingSettings>
17+
{
18+
private readonly IEncodingConversionService _conversionService = conversionService ?? throw new ArgumentNullException(nameof(conversionService));
19+
20+
/// <inheritdoc/>
21+
protected override async Task<int> ExecuteAsync(CommandContext context, FilesConvertEncodingSettings settings, CancellationToken cancellationToken)
22+
{
23+
// Validate settings
24+
if (string.IsNullOrWhiteSpace(settings.Input))
25+
{
26+
AnsiConsole.MarkupLine("[red]Error:[/] Input pattern is required. Use -i/--input to specify a file pattern.");
27+
return 2;
28+
}
29+
30+
if (settings.ConfidenceThreshold is < 0.0 or > 1.0)
31+
{
32+
AnsiConsole.MarkupLine("[red]Error:[/] Confidence threshold must be between 0.0 and 1.0.");
33+
return 2;
34+
}
35+
36+
// Validate input pattern for path traversal
37+
var baseInputPath = settings.Input.Replace("*", string.Empty).Replace("?", string.Empty);
38+
if (!string.IsNullOrEmpty(baseInputPath) && baseInputPath.Contains("..") && !SecurityUtils.IsValidPath(baseInputPath))
39+
{
40+
AnsiConsole.MarkupLine($"[red]Error:[/] Invalid input pattern: '{settings.Input}'. Path traversal not allowed.");
41+
return 2;
42+
}
43+
44+
try
45+
{
46+
// Validate output directory if specified
47+
if (!string.IsNullOrEmpty(settings.OutputDirectory))
48+
{
49+
if (!SecurityUtils.IsValidPath(settings.OutputDirectory))
50+
{
51+
AnsiConsole.MarkupLine($"[red]Error:[/] Invalid output directory path: '{settings.OutputDirectory}'. Path traversal not allowed.");
52+
return 2;
53+
}
54+
55+
Directory.CreateDirectory(settings.OutputDirectory);
56+
}
57+
58+
// Parse exclude patterns
59+
var excludePatterns = new List<string>();
60+
if (!string.IsNullOrEmpty(settings.ExcludePatterns))
61+
{
62+
excludePatterns.AddRange(settings.ExcludePatterns.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
63+
}
64+
65+
// Build options
66+
var options = new EncodingConversionOptions
67+
{
68+
Pattern = settings.Input,
69+
SourceEncoding = settings.SourceEncoding,
70+
TargetEncoding = settings.TargetEncoding,
71+
OutputDirectory = settings.OutputDirectory,
72+
CreateBackup = settings.CreateBackup,
73+
TextOnly = settings.TextOnly,
74+
ConfidenceThreshold = settings.ConfidenceThreshold,
75+
Force = settings.Force,
76+
ExcludePatterns = excludePatterns,
77+
};
78+
79+
// Show what we're doing
80+
AnsiConsole.MarkupLine($"[blue]Converting files matching:[/] {settings.Input}");
81+
AnsiConsole.MarkupLine($"[blue]Target encoding:[/] {settings.TargetEncoding}");
82+
83+
if (!string.IsNullOrEmpty(settings.SourceEncoding))
84+
{
85+
AnsiConsole.MarkupLine($"[blue]Source encoding:[/] {settings.SourceEncoding}");
86+
}
87+
else
88+
{
89+
AnsiConsole.MarkupLine($"[blue]Source encoding:[/] Auto-detect (confidence threshold: {settings.ConfidenceThreshold:P0})");
90+
}
91+
92+
if (settings.TextOnly)
93+
{
94+
AnsiConsole.MarkupLine("[blue]Filter:[/] Text files only");
95+
}
96+
97+
if (settings.CreateBackup)
98+
{
99+
AnsiConsole.MarkupLine("[blue]Backup:[/] Enabled (.bak files will be created)");
100+
}
101+
102+
AnsiConsole.WriteLine();
103+
104+
// Progress reporter
105+
var progress = new Progress<EncodingConversionProgress>(p =>
106+
{
107+
AnsiConsole.MarkupLine($"[grey]{p.ProcessedFiles}/{p.TotalFiles}[/] {p.Message}");
108+
});
109+
110+
// Execute conversion
111+
EncodingConversionResult result;
112+
113+
if (AnsiConsole.Profile.Capabilities.Interactive)
114+
{
115+
result = await AnsiConsole.Status()
116+
.StartAsync("Converting files...", async ctx => await _conversionService.ConvertFilesAsync(options, progress, cancellationToken));
117+
}
118+
else
119+
{
120+
result = await _conversionService.ConvertFilesAsync(options, progress, cancellationToken);
121+
}
122+
123+
// Display results
124+
AnsiConsole.WriteLine();
125+
AnsiConsole.MarkupLine("[green]Conversion complete![/]");
126+
AnsiConsole.WriteLine();
127+
128+
// Summary table
129+
var table = new Table();
130+
table.AddColumn("Metric");
131+
table.AddColumn("Count");
132+
133+
table.AddRow("Total files", result.TotalFiles.ToString());
134+
table.AddRow("[green]Converted[/]", result.ConvertedCount.ToString());
135+
table.AddRow("[yellow]Skipped[/] (already in target encoding)", result.SkippedCount.ToString());
136+
table.AddRow("[red]Errors[/]", result.ErrorCount.ToString());
137+
138+
AnsiConsole.Write(table);
139+
140+
// Show errors if any
141+
if (result.Errors.Count > 0)
142+
{
143+
AnsiConsole.WriteLine();
144+
AnsiConsole.MarkupLine("[red]Errors:[/]");
145+
146+
foreach (var error in result.Errors)
147+
{
148+
AnsiConsole.MarkupLine($" [red]•[/] {Path.GetFileName(error.FilePath)}: {error.ErrorMessage}");
149+
}
150+
}
151+
152+
// Show converted files with details
153+
if (result.ProcessedFiles.Count > 0)
154+
{
155+
AnsiConsole.WriteLine();
156+
AnsiConsole.MarkupLine("[green]Converted files:[/]");
157+
158+
var filesTable = new Table();
159+
filesTable.AddColumn("File");
160+
filesTable.AddColumn("From");
161+
filesTable.AddColumn("To");
162+
filesTable.AddColumn("Confidence");
163+
164+
foreach (var file in result.ProcessedFiles.Take(20)) // Limit to 20 for display
165+
{
166+
var confidenceText = file.DetectionConfidence switch
167+
{
168+
1.0 => "[green]100%[/]",
169+
>= 0.8 => $"[green]{file.DetectionConfidence:P0}[/]",
170+
>= 0.5 => $"[yellow]{file.DetectionConfidence:P0}[/]",
171+
_ => $"[red]{file.DetectionConfidence:P0}[/]",
172+
};
173+
174+
filesTable.AddRow(
175+
Path.GetFileName(file.OriginalPath),
176+
file.SourceEncoding,
177+
file.TargetEncoding,
178+
confidenceText);
179+
}
180+
181+
if (result.ProcessedFiles.Count > 20)
182+
{
183+
filesTable.AddRow($"... and {result.ProcessedFiles.Count - 20} more", "", "", "");
184+
}
185+
186+
AnsiConsole.Write(filesTable);
187+
}
188+
189+
// Return appropriate exit code
190+
return result.ErrorCount > 0 ? 1 : 0;
191+
}
192+
catch (OperationCanceledException)
193+
{
194+
AnsiConsole.MarkupLine("[yellow]Operation cancelled.[/]");
195+
return 130;
196+
}
197+
catch (Exception ex)
198+
{
199+
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
200+
return 1;
201+
}
202+
}
203+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using System.ComponentModel;
2+
3+
using Spectre.Console.Cli;
4+
5+
namespace FurLab.CLI.Commands.Files.ConvertEncoding;
6+
7+
/// <summary>
8+
/// Settings for the file convert-encoding command.
9+
/// </summary>
10+
public sealed class FilesConvertEncodingSettings : CommandSettings
11+
{
12+
/// <summary>
13+
/// Gets the input glob pattern (e.g., "**/*.cs", "src/**/*.txt").
14+
/// </summary>
15+
[CommandOption("-i|--input")]
16+
[Description("Input glob pattern (e.g., **/*.cs, src/**/*.txt).")]
17+
public string Input { get; init; } = string.Empty;
18+
19+
/// <summary>
20+
/// Gets the source encoding. If not specified, auto-detection is used.
21+
/// </summary>
22+
[CommandOption("--from")]
23+
[Description("Source encoding (e.g., UTF-8, Latin1, Windows-1252). If omitted, auto-detection is used.")]
24+
public string? SourceEncoding { get; init; }
25+
26+
/// <summary>
27+
/// Gets the target encoding.
28+
/// </summary>
29+
[CommandOption("--to")]
30+
[Description("Target encoding (e.g., UTF-8, UTF-8-BOM, UTF-16, Latin1, Windows-1252).")]
31+
public string TargetEncoding { get; init; } = "UTF-8";
32+
33+
/// <summary>
34+
/// Gets the output directory. If not specified, files are converted in-place.
35+
/// </summary>
36+
[CommandOption("-o|--output")]
37+
[Description("Output directory. If omitted, files are converted in-place.")]
38+
public string? OutputDirectory { get; init; }
39+
40+
/// <summary>
41+
/// Gets whether to create backup files (.bak) before conversion.
42+
/// </summary>
43+
[CommandOption("--backup")]
44+
[Description("Create backup files (.bak) before conversion.")]
45+
public bool CreateBackup { get; init; }
46+
47+
/// <summary>
48+
/// Gets whether to filter only known text file extensions.
49+
/// </summary>
50+
[CommandOption("--text-only")]
51+
[Description("Filter only known text file extensions.")]
52+
public bool TextOnly { get; init; }
53+
54+
/// <summary>
55+
/// Gets the minimum confidence threshold for auto-detection (0.0 to 1.0).
56+
/// </summary>
57+
[CommandOption("--confidence")]
58+
[Description("Minimum confidence threshold for auto-detection (0.0 to 1.0). Default: 0.8")]
59+
public double ConfidenceThreshold { get; init; } = 0.8;
60+
61+
/// <summary>
62+
/// Gets whether to force conversion even with low confidence.
63+
/// </summary>
64+
[CommandOption("--force")]
65+
[Description("Force conversion even when confidence is below threshold.")]
66+
public bool Force { get; init; }
67+
68+
/// <summary>
69+
/// Gets patterns to exclude (comma-separated).
70+
/// </summary>
71+
[CommandOption("--exclude")]
72+
[Description("Patterns to exclude (comma-separated, e.g., \"node_modules/**,bin/**\").")]
73+
public string? ExcludePatterns { get; init; }
74+
}

FurLab.CLI/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ private static async Task<int> Main(string[] args)
5656
{
5757
file.SetDescription("File utilities.");
5858
file.AddCommand<Commands.Files.Combine.FileCombineCommand>("combine");
59+
file.AddCommand<Commands.Files.ConvertEncoding.FilesConvertEncodingCommand>("convert-encoding");
5960
});
6061

6162
config.AddBranch("claude", claude =>

0 commit comments

Comments
 (0)