diff --git a/Program.cs b/Program.cs index 5187b5c..8bfb3fe 100644 --- a/Program.cs +++ b/Program.cs @@ -2,20 +2,17 @@ using System.Text; using CodeContext.Configuration; using CodeContext.Services; +using CodeContext.Utils; Console.OutputEncoding = Encoding.UTF8; try { - // Initialize dependencies + // Initialize dependencies using functional composition var console = new ConsoleWriter(); var configLoader = new ConfigLoader(console); var pathResolver = new PathResolver(console); var filterConfig = new FilterConfiguration(); - var fileChecker = new FileFilterService(filterConfig); - var scanner = new ProjectScanner(fileChecker, console); - var contentBuilder = new ContentBuilder(scanner); - var outputFormatter = new OutputFormatter(console); var statsCalculator = new StatsCalculator(); // Load configuration @@ -26,6 +23,19 @@ console.Write($"Enter the path to index (default: {defaultInputPath}): "); var projectPath = pathResolver.GetInputPath(defaultInputPath); + // Load GitIgnore patterns for the project (I/O boundary clearly defined) + var gitIgnoreParser = GitHelper.FindRepositoryRoot(projectPath) switch + { + null => GitIgnoreParser.Empty, + var gitRoot => GitIgnoreParser.FromFile(Path.Combine(gitRoot, ".gitignore")) + }; + + // Initialize file checker with immutable GitIgnore parser + var fileChecker = new FileFilterService(filterConfig, gitIgnoreParser); + var scanner = new ProjectScanner(fileChecker, console); + var contentBuilder = new ContentBuilder(scanner); + var outputFormatter = new OutputFormatter(console); + // Determine output path var folderName = PathResolver.GetFolderName(projectPath); var defaultFileName = $"{folderName}_{config.DefaultOutputFileName}"; diff --git a/Services/ConfigLoader.cs b/Services/ConfigLoader.cs index 82de8b2..3d36064 100644 --- a/Services/ConfigLoader.cs +++ b/Services/ConfigLoader.cs @@ -5,10 +5,11 @@ namespace CodeContext.Services; /// -/// Loads application configuration from config.json. +/// Functional configuration loader with separated I/O and parsing logic. /// public class ConfigLoader { + private const string ConfigFileName = "config.json"; private readonly IConsoleWriter _console; public ConfigLoader(IConsoleWriter console) @@ -18,13 +19,39 @@ public ConfigLoader(IConsoleWriter console) /// /// Loads configuration from config.json if it exists, otherwise returns default configuration. + /// I/O operation with functional error handling. /// - public AppConfig Load() + public AppConfig Load() => + ReadConfigFile(ConfigFileName) + .Match( + onSuccess: ParseConfig, + onError: HandleParseError); + + /// + /// I/O operation: reads config file or returns empty JSON. + /// + private static Result ReadConfigFile(string fileName) + { + try + { + var json = File.Exists(fileName) ? File.ReadAllText(fileName) : "{}"; + return new Result.Success(json); + } + catch (Exception ex) + { + return new Result.Error(ex.Message); + } + } + + /// + /// Parses JSON string into AppConfig. + /// Includes console logging side effects on parse errors. + /// + private AppConfig ParseConfig(string json) { try { - var configJson = File.Exists("config.json") ? File.ReadAllText("config.json") : "{}"; - return JsonSerializer.Deserialize(configJson) ?? new AppConfig(); + return JsonSerializer.Deserialize(json) ?? new AppConfig(); } catch (JsonException ex) { @@ -32,4 +59,34 @@ public AppConfig Load() return new AppConfig(); } } + + /// + /// Error handler: returns default config and logs error. + /// + private AppConfig HandleParseError(string error) + { + _console.WriteLine($"⚠️ Warning: Could not read config.json ({error}). Using defaults."); + return new AppConfig(); + } + + /// + /// Simple Result type for functional error handling. + /// + private abstract record Result + { + private Result() { } + + public sealed record Success(T Value) : Result; + public sealed record Error(string Message) : Result; + + public TResult Match( + Func onSuccess, + Func onError) => + this switch + { + Success s => onSuccess(s.Value), + Error e => onError(e.Message), + _ => throw new InvalidOperationException() + }; + } } diff --git a/Services/ContentBuilder.cs b/Services/ContentBuilder.cs index a6da355..e36e2d8 100644 --- a/Services/ContentBuilder.cs +++ b/Services/ContentBuilder.cs @@ -1,10 +1,11 @@ -using System.Text; +using System.Collections.Immutable; using CodeContext.Configuration; namespace CodeContext.Services; /// -/// Builds project context content based on configuration. +/// Builds project context content using functional composition. +/// Separates content generation from assembly. /// public class ContentBuilder { @@ -17,26 +18,51 @@ public ContentBuilder(ProjectScanner scanner) /// /// Builds the complete content output including structure and file contents. + /// Uses functional composition to build content sections. /// /// The directory path to process. /// The configuration specifying what to include. /// The complete output content. - public string Build(string projectPath, AppConfig config) + public string Build(string projectPath, AppConfig config) => + string.Join("\n", BuildContentSections(projectPath, config)); + + /// + /// Generates content sections based on configuration (performs I/O via scanner). + /// Uses declarative approach with LINQ and immutable collections. + /// + private IEnumerable BuildContentSections(string projectPath, AppConfig config) { - var content = new StringBuilder(); + var sections = ImmutableArray.CreateBuilder(); if (config.IncludeStructure) { - content.AppendLine("Project Structure:") - .AppendLine(_scanner.GetProjectStructure(projectPath)); + sections.Add(new ContentSection( + "Project Structure:", + () => _scanner.GetProjectStructure(projectPath))); } if (config.IncludeContents) { - content.AppendLine("\nFile Contents:") - .AppendLine(_scanner.GetFileContents(projectPath)); + sections.Add(new ContentSection( + "\nFile Contents:", + () => _scanner.GetFileContents(projectPath))); } - return content.ToString(); + return sections.ToImmutable().SelectMany(section => section.Render()); + } + + /// + /// Immutable record representing a content section with lazy evaluation. + /// + private sealed record ContentSection(string Header, Func ContentGenerator) + { + /// + /// Renders the section by evaluating the content generator. + /// + public IEnumerable Render() + { + yield return Header; + yield return ContentGenerator(); + } } } diff --git a/Services/FileFilterService.cs b/Services/FileFilterService.cs index 1465925..031592e 100644 --- a/Services/FileFilterService.cs +++ b/Services/FileFilterService.cs @@ -5,18 +5,27 @@ namespace CodeContext.Services; /// -/// Service for determining if files and directories should be filtered out during processing. +/// Functional service for determining if files and directories should be filtered out during processing. +/// Uses composable predicates and immutable state. /// public class FileFilterService : IFileChecker { private readonly FilterConfiguration _config; - private readonly GitIgnoreParser _gitIgnoreParser; - private bool _gitIgnoreLoaded; + private readonly Lazy _gitIgnoreParser; public FileFilterService(FilterConfiguration config) { _config = Guard.NotNull(config, nameof(config)); - _gitIgnoreParser = new GitIgnoreParser(); + _gitIgnoreParser = new Lazy(() => GitIgnoreParser.Empty); + } + + /// + /// Creates a FileFilterService with a pre-loaded GitIgnoreParser. + /// + public FileFilterService(FilterConfiguration config, GitIgnoreParser gitIgnoreParser) + { + _config = Guard.NotNull(config, nameof(config)); + _gitIgnoreParser = new Lazy(() => gitIgnoreParser); } public bool ShouldSkip(FileSystemInfo info, string rootPath) @@ -24,71 +33,55 @@ public bool ShouldSkip(FileSystemInfo info, string rootPath) Guard.NotNull(info, nameof(info)); Guard.NotNullOrEmpty(rootPath, nameof(rootPath)); - // Skip symbolic links and junction points to avoid I/O errors - if (info.Attributes.HasFlag(FileAttributes.ReparsePoint)) - { - return true; - } - - // Check if any parent directory is in the ignored list - var relativePath = Path.GetRelativePath(rootPath, info.FullName); - var pathParts = relativePath.Split(Path.DirectorySeparatorChar); - - if (pathParts.Any(_config.IgnoredDirectories.Contains)) - { - return true; - } + // Compose filter predicates - each is a pure function or has well-defined side effects + return IsReparsePoint(info) + || ContainsIgnoredDirectory(info, rootPath) + || (IsFile(info) && ShouldSkipFile(info, rootPath)); + } - if (info.Attributes.HasFlag(FileAttributes.Directory)) - { - return false; // We've already checked if it's an ignored directory - } + private static bool IsReparsePoint(FileSystemInfo info) => + info.Attributes.HasFlag(FileAttributes.ReparsePoint); - // Check for ignored files - if (_config.IgnoredFiles.Contains(info.Name)) - { - return true; - } + private static bool IsFile(FileSystemInfo info) => + !info.Attributes.HasFlag(FileAttributes.Directory); - // Check file extension - if (ShouldSkipByExtension(info.Name)) - { - return true; - } + private bool ContainsIgnoredDirectory(FileSystemInfo info, string rootPath) + { + var relativePath = Path.GetRelativePath(rootPath, info.FullName); + var pathParts = relativePath.Split(Path.DirectorySeparatorChar); + return pathParts.Any(_config.IgnoredDirectories.Contains); + } - // Check file size - if (info is FileInfo fileInfo && fileInfo.Length > _config.MaxFileSizeBytes) - { - return true; - } + private bool ShouldSkipFile(FileSystemInfo info, string rootPath) => + IsIgnoredFileName(info.Name) + || ShouldSkipByExtension(info.Name) + || IsFileTooLarge(info) + || ShouldSkipByGitIgnore(info.FullName, rootPath) + || IsBinaryFile(info.FullName) + || IsGeneratedCode(info.FullName); - // Check gitignore patterns - if (ShouldSkipByGitIgnore(info.FullName, rootPath)) - { - return true; - } + private bool IsIgnoredFileName(string fileName) => + _config.IgnoredFiles.Contains(fileName); - // Check if binary - if (FileUtilities.IsBinaryFile(info.FullName, _config.BinaryCheckChunkSize, _config.BinaryThreshold)) - { - return true; - } + private bool IsFileTooLarge(FileSystemInfo info) => + info is FileInfo fileInfo && fileInfo.Length > _config.MaxFileSizeBytes; - // Check for generated code - if (IsGeneratedCode(info.FullName)) - { - return true; - } + private bool IsBinaryFile(string filePath) => + FileUtilities.IsBinaryFile(filePath, _config.BinaryCheckChunkSize, _config.BinaryThreshold); - return false; - } + private bool ShouldSkipByExtension(string fileName) => + GetExtensions(fileName).Any(_config.IgnoredExtensions.Contains); - private bool ShouldSkipByExtension(string fileName) + /// + /// Pure function that extracts both simple and compound extensions from a filename. + /// For "file.min.css", returns [".css", ".min.css"] + /// + private static IEnumerable GetExtensions(string fileName) { var extension = Path.GetExtension(fileName); - if (_config.IgnoredExtensions.Contains(extension)) + if (!string.IsNullOrEmpty(extension)) { - return true; + yield return extension; } // Check for compound extensions like .min.css @@ -98,52 +91,47 @@ private bool ShouldSkipByExtension(string fileName) var secondLastDotIndex = fileName.LastIndexOf('.', lastDotIndex - 1); if (secondLastDotIndex >= 0) { - var compoundExtension = fileName.Substring(secondLastDotIndex); - if (_config.IgnoredExtensions.Contains(compoundExtension)) - { - return true; - } + yield return fileName[secondLastDotIndex..]; } } - - return false; } - private bool ShouldSkipByGitIgnore(string filePath, string rootPath) - { - if (!GitHelper.IsInRepository(rootPath)) - { - return false; - } - - if (!_gitIgnoreLoaded) + private bool ShouldSkipByGitIgnore(string filePath, string rootPath) => + GitHelper.FindRepositoryRoot(rootPath) switch { - var gitRoot = GitHelper.FindRepositoryRoot(rootPath) ?? rootPath; - var gitIgnorePath = Path.Combine(gitRoot, ".gitignore"); - _gitIgnoreParser.LoadFromFile(gitIgnorePath); - _gitIgnoreLoaded = true; - } + null => false, + var repoRoot => IsIgnoredInRepository(filePath, repoRoot) + }; - if (!_gitIgnoreParser.HasPatterns) + private bool IsIgnoredInRepository(string filePath, string repositoryRoot) + { + var parser = _gitIgnoreParser.Value; + if (!parser.HasPatterns) { return false; } - var repositoryRoot = GitHelper.FindRepositoryRoot(rootPath) ?? rootPath; var relativePath = Path.GetRelativePath(repositoryRoot, filePath); - return _gitIgnoreParser.IsIgnored(relativePath); + return parser.IsIgnored(relativePath); } - private bool IsGeneratedCode(string filePath) + private bool IsGeneratedCode(string filePath) => + ReadFirstLines(filePath, _config.GeneratedCodeLinesToCheck) + .Any(line => line.Contains("")); + + /// + /// Pure I/O function: reads first N lines from a file. + /// Returns empty enumerable on error (isolates exception handling). + /// + private static IEnumerable ReadFirstLines(string filePath, int count) { try { - var lines = File.ReadLines(filePath).Take(_config.GeneratedCodeLinesToCheck); - return lines.Any(line => line.Contains("")); + return File.ReadLines(filePath).Take(count); } catch { - return false; + return Enumerable.Empty(); } } } diff --git a/Services/GitIgnoreParser.cs b/Services/GitIgnoreParser.cs index a361d84..9e5cbad 100644 --- a/Services/GitIgnoreParser.cs +++ b/Services/GitIgnoreParser.cs @@ -1,68 +1,108 @@ +using System.Collections.Immutable; using System.Text.RegularExpressions; namespace CodeContext.Services; /// -/// Handles parsing and matching of .gitignore patterns. +/// Immutable gitignore pattern matcher using functional programming principles. +/// Separates I/O operations from pure pattern matching logic. /// -public class GitIgnoreParser +public sealed record GitIgnoreParser { - private readonly List _patterns = new(); - private readonly Dictionary _regexCache = new(); + private readonly ImmutableArray _patterns; + private readonly Lazy> _regexCache; + + private GitIgnoreParser(ImmutableArray patterns) + { + _patterns = patterns; + _regexCache = new Lazy>(() => + CreateRegexCache(patterns)); + } /// - /// Loads .gitignore patterns from a file. + /// Creates an empty GitIgnoreParser with no patterns. + /// + public static GitIgnoreParser Empty { get; } = new GitIgnoreParser(ImmutableArray.Empty); + + /// + /// Creates a GitIgnoreParser from a collection of patterns (pure function). + /// + /// The gitignore patterns to use. + /// A new immutable GitIgnoreParser instance. + public static GitIgnoreParser FromPatterns(IEnumerable patterns) + { + var validPatterns = patterns + .Where(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith('#')) + .ToImmutableArray(); + + return validPatterns.IsEmpty ? Empty : new GitIgnoreParser(validPatterns); + } + + /// + /// Reads gitignore patterns from a file (I/O operation). + /// Returns Empty parser if file doesn't exist or can't be read. /// /// Path to the .gitignore file. - public void LoadFromFile(string gitIgnorePath) + /// A new GitIgnoreParser with patterns from the file. + public static GitIgnoreParser FromFile(string gitIgnorePath) { if (!File.Exists(gitIgnorePath)) { - return; + return Empty; } - _patterns.Clear(); - _regexCache.Clear(); - - var lines = File.ReadAllLines(gitIgnorePath) - .Where(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith('#')); - - _patterns.AddRange(lines); + try + { + var lines = File.ReadAllLines(gitIgnorePath); + return FromPatterns(lines); + } + catch + { + return Empty; + } } /// - /// Checks if a relative path matches any loaded gitignore patterns. + /// Checks if a relative path matches any gitignore patterns (pure function). /// /// The relative path to check. /// True if the path should be ignored; otherwise, false. - public bool IsIgnored(string relativePath) - { - return _patterns.Any(pattern => IsMatch(relativePath, pattern)); - } + public bool IsIgnored(string relativePath) => + _patterns.Any(pattern => IsMatch(relativePath, pattern)); /// /// Checks if there are any loaded patterns. /// - public bool HasPatterns => _patterns.Count > 0; + public bool HasPatterns => !_patterns.IsEmpty; + + /// + /// Gets the number of patterns. + /// + public int PatternCount => _patterns.Length; private bool IsMatch(string path, string pattern) { - if (!_regexCache.TryGetValue(pattern, out var regex)) + var cache = _regexCache.Value; + if (!cache.TryGetValue(pattern, out var regex)) { + // This shouldn't happen as cache is pre-computed, but handle defensively var regexPattern = ConvertGitIgnorePatternToRegex(pattern); regex = new Regex($"^{regexPattern}$", RegexOptions.IgnoreCase | RegexOptions.Compiled); - _regexCache[pattern] = regex; } return regex.IsMatch(path); } - private static string ConvertGitIgnorePatternToRegex(string pattern) - { - // Simple conversion - could be enhanced for full gitignore spec - return pattern + private static ImmutableDictionary CreateRegexCache(ImmutableArray patterns) => + patterns.ToImmutableDictionary( + pattern => pattern, + pattern => new Regex( + $"^{ConvertGitIgnorePatternToRegex(pattern)}$", + RegexOptions.IgnoreCase | RegexOptions.Compiled)); + + private static string ConvertGitIgnorePatternToRegex(string pattern) => + pattern .Replace(".", "\\.") .Replace("*", ".*") .Replace("?", "."); - } } diff --git a/Services/OutputFormatter.cs b/Services/OutputFormatter.cs index 9bb3238..6aa445b 100644 --- a/Services/OutputFormatter.cs +++ b/Services/OutputFormatter.cs @@ -4,7 +4,7 @@ namespace CodeContext.Services; /// -/// Formats and writes output to files in various formats. +/// Functional output formatter with separated I/O and formatting logic. /// public class OutputFormatter { @@ -16,7 +16,8 @@ public OutputFormatter(IConsoleWriter console) } /// - /// Writes content to the specified output location. + /// Writes content to the specified output location (I/O operation). + /// Composed from pure formatting and impure I/O functions. /// /// Target path (file or directory). /// Content to write. @@ -28,36 +29,50 @@ public string WriteToFile(string outputTarget, string content, string format, st _console.WriteLine("\n💾 Writing output..."); var resolvedPath = ResolveOutputPath(outputTarget, defaultFileName); - EnsureDirectoryExists(resolvedPath); + var formattedContent = FormatContent(content, format, DateTime.Now); - var formattedContent = FormatContent(content, format); - File.WriteAllText(resolvedPath, formattedContent); + WriteFile(resolvedPath, formattedContent); return resolvedPath; } - private static string ResolveOutputPath(string outputTarget, string defaultFileName) - { - return Directory.Exists(outputTarget) + /// + /// I/O operation: resolves output path based on target type. + /// Checks if directory exists before combining paths. + /// + private static string ResolveOutputPath(string outputTarget, string defaultFileName) => + Directory.Exists(outputTarget) ? Path.Combine(outputTarget, defaultFileName) : outputTarget; - } - private static void EnsureDirectoryExists(string filePath) + /// + /// I/O operation: ensures directory exists and writes file. + /// + private static void WriteFile(string filePath, string content) { var directory = Path.GetDirectoryName(filePath); if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { Directory.CreateDirectory(directory); } + + File.WriteAllText(filePath, content); } - private static string FormatContent(string content, string format) - { - return format.ToLower() == "json" - ? JsonSerializer.Serialize( - new { content, timestamp = DateTime.Now }, - new JsonSerializerOptions { WriteIndented = true }) + /// + /// Pure function: formats content based on output format. + /// + private static string FormatContent(string content, string format, DateTime timestamp) => + format.Equals("json", StringComparison.OrdinalIgnoreCase) + ? SerializeToJson(content, timestamp) : content; - } + + /// + /// Pure function: serializes content to JSON with provided timestamp. + /// Deterministic - same inputs always produce same output. + /// + private static string SerializeToJson(string content, DateTime timestamp) => + JsonSerializer.Serialize( + new { content, timestamp }, + new JsonSerializerOptions { WriteIndented = true }); } diff --git a/Services/PathResolver.cs b/Services/PathResolver.cs index 32555e4..d02107d 100644 --- a/Services/PathResolver.cs +++ b/Services/PathResolver.cs @@ -5,6 +5,7 @@ namespace CodeContext.Services; /// /// Resolves and validates input and output paths with user interaction. +/// Separates pure path operations from I/O side effects. /// public class PathResolver { @@ -16,81 +17,96 @@ public PathResolver(IConsoleWriter console) } /// - /// Gets and validates the input directory path. + /// Gets and validates the input directory path (I/O operation). /// /// The default path to use if user doesn't provide one. /// The validated full path to the input directory. public string GetInputPath(string defaultPath) { var userPath = _console.ReadLine() ?? string.Empty; - var selectedPath = string.IsNullOrWhiteSpace(userPath) ? defaultPath : userPath; + var selectedPath = SelectPath(userPath, defaultPath); var fullPath = Path.GetFullPath(selectedPath); return Guard.DirectoryExists(fullPath, nameof(fullPath)); } /// - /// Gets and validates the output file path. + /// Gets and validates the output file path (I/O operation). /// /// Optional output path from command-line arguments. /// Default output path if none provided. /// The validated full output path. public string GetOutputPath(string? commandLineArg, string defaultPath) { - if (!string.IsNullOrWhiteSpace(commandLineArg)) + var selectedPath = commandLineArg switch { - return Path.GetFullPath(commandLineArg); - } + not null when !string.IsNullOrWhiteSpace(commandLineArg) => commandLineArg, + _ => SelectPath(_console.ReadLine() ?? string.Empty, defaultPath) + }; - var userInput = _console.ReadLine() ?? string.Empty; - return string.IsNullOrWhiteSpace(userInput) - ? defaultPath - : Path.GetFullPath(userInput); + return Path.GetFullPath(selectedPath); } + /// + /// Pure function: selects between user input and default path. + /// + private static string SelectPath(string userInput, string defaultPath) => + string.IsNullOrWhiteSpace(userInput) ? defaultPath : userInput; + /// /// Extracts a clean folder name from the input path for output file naming. + /// Uses functional composition to handle edge cases. May perform I/O. /// /// The input path. /// A sanitized folder name. - public static string GetFolderName(string path) - { - var folderName = new DirectoryInfo(path).Name; - - if (!string.IsNullOrEmpty(folderName) && folderName != ".") - { - return folderName; - } - - folderName = new DirectoryInfo(Environment.CurrentDirectory).Name; + public static string GetFolderName(string path) => + TryGetDirectoryName(path) + ?? TryGetCurrentDirectoryName() + ?? (IsPathSeparatorTerminated(path) ? ExtractRootFolderName(path) ?? "root" : "root"); - if (IsPathSeparatorTerminated(path)) - { - return ExtractRootFolderName(path) ?? "root"; - } - - return folderName; - } - - private static bool IsPathSeparatorTerminated(string path) + /// + /// Attempts to get directory name from path (may perform I/O). + /// Returns null if invalid or current directory marker. + /// + private static string? TryGetDirectoryName(string path) { - return path.EndsWith(Path.DirectorySeparatorChar.ToString()) || - path.EndsWith(Path.AltDirectorySeparatorChar.ToString()); + var name = new DirectoryInfo(path).Name; + return !string.IsNullOrEmpty(name) && name != "." ? name : null; } - private static string? ExtractRootFolderName(string path) - { - var root = Path.GetPathRoot(Path.GetFullPath(path)); - if (string.IsNullOrEmpty(root)) - { - return null; - } + /// + /// I/O function: gets current directory name. + /// + private static string? TryGetCurrentDirectoryName() => + new DirectoryInfo(Environment.CurrentDirectory).Name; - var cleanedRoot = root - .Replace(Path.DirectorySeparatorChar.ToString(), "") - .Replace(Path.AltDirectorySeparatorChar.ToString(), "") - .Replace(":", ""); + /// + /// Pure predicate: checks if path ends with directory separator. + /// + private static bool IsPathSeparatorTerminated(string path) => + path.EndsWith(Path.DirectorySeparatorChar) || + path.EndsWith(Path.AltDirectorySeparatorChar); - return string.IsNullOrEmpty(cleanedRoot) ? null : cleanedRoot; - } + /// + /// Extracts root folder name from path (may perform I/O). + /// Removes path separators and drive colons. + /// + private static string? ExtractRootFolderName(string path) => + Path.GetPathRoot(Path.GetFullPath(path)) switch + { + null or "" => null, + var root => CleanRootPath(root) switch + { + "" => null, + var cleaned => cleaned + } + }; + + /// + /// Pure function: removes separators and colons from root path. + /// + private static string CleanRootPath(string root) => + root.Replace(Path.DirectorySeparatorChar.ToString(), string.Empty) + .Replace(Path.AltDirectorySeparatorChar.ToString(), string.Empty) + .Replace(":", string.Empty); } diff --git a/Services/ProjectScanner.cs b/Services/ProjectScanner.cs index 8da08cc..fcfde5a 100644 --- a/Services/ProjectScanner.cs +++ b/Services/ProjectScanner.cs @@ -1,17 +1,17 @@ -using System.Text; +using System.Collections.Immutable; using CodeContext.Interfaces; using CodeContext.Utils; namespace CodeContext.Services; /// -/// Service for scanning and analyzing project directories. +/// Functional service for scanning and analyzing project directories. +/// Uses immutable data structures and separates I/O from pure logic. /// public class ProjectScanner { private readonly IFileChecker _fileChecker; private readonly IConsoleWriter _console; - private string? _gitRepoRoot; public ProjectScanner(IFileChecker fileChecker, IConsoleWriter console) { @@ -20,7 +20,7 @@ public ProjectScanner(IFileChecker fileChecker, IConsoleWriter console) } /// - /// Gets user input with a prompt. + /// Gets user input with a prompt (pure I/O operation). /// /// The prompt to display. /// The user's input. @@ -30,6 +30,15 @@ public string GetUserInput(string prompt) return _console.ReadLine() ?? string.Empty; } + /// + /// Represents the context for a scan operation (immutable). + /// + private sealed record ScanContext(string RootPath, string GitRepoRoot) + { + public static ScanContext Create(string projectPath) => + new(projectPath, GitHelper.FindRepositoryRoot(projectPath) ?? projectPath); + } + /// /// Generates a hierarchical structure representation of the project directory. /// @@ -39,69 +48,105 @@ public string GetUserInput(string prompt) public string GetProjectStructure(string projectPath, int indent = 0) { Guard.DirectoryExists(projectPath, nameof(projectPath)); - _gitRepoRoot ??= GitHelper.FindRepositoryRoot(projectPath); if (indent == 0) { _console.WriteLine("📁 Analyzing directory structure..."); } - var rootPath = _gitRepoRoot ?? projectPath; - - List entries; - try - { - var enumerationOptions = new EnumerationOptions - { - IgnoreInaccessible = true, - RecurseSubdirectories = false - }; + var context = ScanContext.Create(projectPath); + var lines = GetProjectStructureLines(projectPath, context, indent).ToImmutableArray(); - entries = Directory.EnumerateFileSystemEntries(projectPath, "*", enumerationOptions) - .OrderBy(e => e) - .Where(e => - { - try - { - return !_fileChecker.ShouldSkip(new FileInfo(e), rootPath); - } - catch - { - // Skip entries that can't be accessed - return false; - } - }) - .ToList(); - } - catch (Exception ex) + // Report progress (side effect isolated to specific calls) + if (indent == 0 && lines.Length > 0) { - _console.WriteLine($"\n⚠️ Warning: Could not enumerate directory {projectPath}: {ex.Message}"); - return string.Empty; + _console.Write($"\r⏳ Progress: 100% ({lines.Length}/{lines.Length})"); } - var structure = new StringBuilder(); + return string.Join(Environment.NewLine, lines); + } - for (int i = 0; i < entries.Count; i++) - { - WriteProgress(i + 1, entries.Count); - var entry = entries[i]; + /// + /// Recursive function to generate directory structure lines (performs I/O). + /// Uses lazy evaluation via yield return. + /// + private IEnumerable GetProjectStructureLines(string directoryPath, ScanContext context, int indent) + { + var entries = GetFilteredEntries(directoryPath, context.GitRepoRoot); + foreach (var entry in entries) + { if (Directory.Exists(entry)) { var dir = new DirectoryInfo(entry); - structure.AppendLine($"{new string(' ', indent * 2)}[{dir.Name}/]"); - structure.Append(GetProjectStructure(entry, indent + 1)); + yield return FormatDirectoryEntry(dir.Name, indent); + + // Recursively yield subdirectory contents + foreach (var line in GetProjectStructureLines(entry, context, indent + 1)) + { + yield return line; + } } else { var file = new FileInfo(entry); - structure.AppendLine($"{new string(' ', indent * 2)}[{file.Extension}] {file.Name}"); + yield return FormatFileEntry(file.Name, file.Extension, indent); } } + } - return structure.ToString(); + /// + /// I/O operation: gets filtered and sorted directory entries. + /// Includes console logging side effects on errors. + /// + private IEnumerable GetFilteredEntries(string directoryPath, string rootPath) + { + try + { + var options = new EnumerationOptions + { + IgnoreInaccessible = true, + RecurseSubdirectories = false + }; + + return Directory.EnumerateFileSystemEntries(directoryPath, "*", options) + .OrderBy(e => e) + .Where(e => ShouldIncludeEntry(e, rootPath)); + } + catch (Exception ex) + { + _console.WriteLine($"\n⚠️ Warning: Could not enumerate directory {directoryPath}: {ex.Message}"); + return Enumerable.Empty(); + } } + /// + /// Predicate to determine if an entry should be included (may perform I/O). + /// + private bool ShouldIncludeEntry(string entryPath, string rootPath) + { + try + { + return !_fileChecker.ShouldSkip(new FileInfo(entryPath), rootPath); + } + catch + { + return false; + } + } + + /// + /// Pure function to format a directory entry. + /// + private static string FormatDirectoryEntry(string name, int indent) => + $"{new string(' ', indent * 2)}[{name}/]"; + + /// + /// Pure function to format a file entry. + /// + private static string FormatFileEntry(string name, string extension, int indent) => + $"{new string(' ', indent * 2)}[{extension}] {name}"; + /// /// Retrieves the contents of all non-filtered files in the directory tree. /// @@ -110,93 +155,138 @@ public string GetProjectStructure(string projectPath, int indent = 0) public string GetFileContents(string projectPath) { Guard.DirectoryExists(projectPath, nameof(projectPath)); - _gitRepoRoot ??= GitHelper.FindRepositoryRoot(projectPath); _console.WriteLine("\n📄 Processing files..."); - var rootPath = _gitRepoRoot ?? projectPath; - var files = new List(); + var context = ScanContext.Create(projectPath); + var files = EnumerateFilesRecursively(projectPath, context.GitRepoRoot).ToImmutableArray(); + + var fileContents = files + .Select((file, index) => + { + WriteProgress(index + 1, files.Length); + return ReadFileWithSeparator(file); + }) + .Where(content => content != null) + .ToImmutableArray(); - // Manually enumerate files recursively to respect filters - EnumerateFilesRecursively(projectPath, rootPath, files); + return string.Join("\n\n", fileContents!); + } + + /// + /// Recursive I/O operation to enumerate all files in directory tree. + /// Uses lazy evaluation via yield return. + /// + private IEnumerable EnumerateFilesRecursively(string directory, string rootPath) + { + var options = new EnumerationOptions + { + IgnoreInaccessible = true, + RecurseSubdirectories = false + }; - var fileContents = new List(); - for (int i = 0; i < files.Count; i++) + // Yield files in current directory + foreach (var file in GetFilteredFiles(directory, rootPath, options)) { - WriteProgress(i + 1, files.Count); - var file = files[i]; + yield return file; + } - try - { - var content = File.ReadAllText(file); - fileContents.Add($"{file}\n{new string('-', 100)}\n{content}"); - } - catch (Exception ex) + // Recursively yield files from subdirectories + foreach (var subDir in GetFilteredDirectories(directory, rootPath, options)) + { + foreach (var file in EnumerateFilesRecursively(subDir, rootPath)) { - _console.WriteLine($"\n⚠️ Warning: Could not read file {file}: {ex.Message}"); + yield return file; } } + } - return string.Join("\n\n", fileContents); + /// + /// I/O operation: gets filtered files from a directory. + /// Includes console logging side effects on errors. + /// + private IEnumerable GetFilteredFiles(string directory, string rootPath, EnumerationOptions options) + { + try + { + return Directory.EnumerateFiles(directory, "*", options) + .Where(file => ShouldIncludeFile(file, rootPath)); + } + catch (Exception ex) + { + _console.WriteLine($"\n⚠️ Warning: Could not enumerate directory {directory}: {ex.Message}"); + return Enumerable.Empty(); + } } /// - /// Recursively enumerates files while respecting filter rules. + /// I/O operation: gets filtered subdirectories from a directory. /// - private void EnumerateFilesRecursively(string directory, string rootPath, List files) + private IEnumerable GetFilteredDirectories(string directory, string rootPath, EnumerationOptions options) { try { - var enumerationOptions = new EnumerationOptions - { - IgnoreInaccessible = true, - RecurseSubdirectories = false - }; + return Directory.EnumerateDirectories(directory, "*", options) + .Where(dir => ShouldIncludeDirectory(dir, rootPath)); + } + catch + { + return Enumerable.Empty(); + } + } - // Get files in current directory - foreach (var file in Directory.EnumerateFiles(directory, "*", enumerationOptions)) - { - try - { - var fileInfo = new FileInfo(file); - if (!_fileChecker.ShouldSkip(fileInfo, rootPath)) - { - files.Add(file); - } - } - catch - { - // Skip files that can't be accessed - } - } + /// + /// Predicate to check if a file should be included (may perform I/O). + /// + private bool ShouldIncludeFile(string filePath, string rootPath) + { + try + { + var fileInfo = new FileInfo(filePath); + return !_fileChecker.ShouldSkip(fileInfo, rootPath); + } + catch + { + return false; + } + } - // Recursively process subdirectories - foreach (var subDir in Directory.EnumerateDirectories(directory, "*", enumerationOptions)) - { - try - { - var dirInfo = new DirectoryInfo(subDir); - if (!_fileChecker.ShouldSkip(dirInfo, rootPath)) - { - EnumerateFilesRecursively(subDir, rootPath, files); - } - } - catch - { - // Skip directories that can't be accessed - } - } + /// + /// Predicate to check if a directory should be included (may perform I/O). + /// + private bool ShouldIncludeDirectory(string dirPath, string rootPath) + { + try + { + var dirInfo = new DirectoryInfo(dirPath); + return !_fileChecker.ShouldSkip(dirInfo, rootPath); } - catch (Exception ex) + catch { - _console.WriteLine($"\n⚠️ Warning: Could not enumerate directory {directory}: {ex.Message}"); + return false; } } /// - /// Gets the root path of the git repository containing the scanned path. + /// I/O function to read file with formatted separator. + /// Returns null on error for filtering. /// - public string? GitRepoRoot => _gitRepoRoot; + private string? ReadFileWithSeparator(string filePath) + { + try + { + var content = File.ReadAllText(filePath); + return $"{filePath}\n{new string('-', 100)}\n{content}"; + } + catch (Exception ex) + { + _console.WriteLine($"\n⚠️ Warning: Could not read file {filePath}: {ex.Message}"); + return null; + } + } + /// + /// Side effect: writes progress to console. + /// private void WriteProgress(int current, int total) { var percent = (int)((current / (double)total) * 100); diff --git a/Services/StatsCalculator.cs b/Services/StatsCalculator.cs index 49408f8..68d8628 100644 --- a/Services/StatsCalculator.cs +++ b/Services/StatsCalculator.cs @@ -1,36 +1,78 @@ namespace CodeContext.Services; /// -/// Calculates statistics about processed projects. +/// Functional statistics calculator with separated I/O and formatting logic. /// public class StatsCalculator { /// /// Calculates and formats statistics about the processing operation. + /// Separates I/O (file counting) from pure calculations. /// /// The directory that was processed. /// The generated content. /// Time elapsed during processing. /// Formatted statistics string. public string Calculate(string projectPath, string content, TimeSpan elapsed) + { + var stats = GatherStats(projectPath, content, elapsed); + return FormatStats(stats); + } + + /// + /// I/O operation: gathers statistics from file system and content. + /// Returns null on error for functional error handling. + /// + private static ProjectStats? GatherStats(string projectPath, string content, TimeSpan elapsed) { try { - var fileCount = Directory.GetFiles(projectPath, "*", SearchOption.AllDirectories).Length; - var lineCount = content.Count(c => c == '\n'); - - return $""" + var fileCount = CountFiles(projectPath); + var lineCount = CountLines(content); - 📊 Stats: - 📁 Files processed: {fileCount} - 📝 Total lines: {lineCount} - ⏱️ Time taken: {elapsed.TotalSeconds:F2}s - 💾 Output size: {content.Length} characters - """; + return new ProjectStats(fileCount, lineCount, elapsed, content.Length); } catch { - return "\n📊 Stats: Unable to calculate statistics"; + return null; } } + + /// + /// I/O operation: counts all files in directory tree. + /// + private static int CountFiles(string directoryPath) => + Directory.GetFiles(directoryPath, "*", SearchOption.AllDirectories).Length; + + /// + /// Pure function: counts newline characters in content. + /// + private static int CountLines(string content) => + content.Count(c => c == '\n'); + + /// + /// Pure function: formats statistics into display string. + /// + private static string FormatStats(ProjectStats? stats) => + stats switch + { + null => "\n📊 Stats: Unable to calculate statistics", + var s => $""" + + 📊 Stats: + 📁 Files processed: {s.FileCount} + 📝 Total lines: {s.LineCount} + ⏱️ Time taken: {s.Elapsed.TotalSeconds:F2}s + 💾 Output size: {s.ContentLength} characters + """ + }; + + /// + /// Immutable record holding project statistics. + /// + private sealed record ProjectStats( + int FileCount, + int LineCount, + TimeSpan Elapsed, + int ContentLength); } diff --git a/Utils/FileUtilities.cs b/Utils/FileUtilities.cs index 8beb376..97d5f5e 100644 --- a/Utils/FileUtilities.cs +++ b/Utils/FileUtilities.cs @@ -1,12 +1,16 @@ namespace CodeContext.Utils; /// -/// Utility methods for file operations. +/// Functional utility methods for file operations. +/// Separates I/O operations from pure byte analysis logic. /// public static class FileUtilities { + private static readonly byte[] Utf8Bom = { 0xEF, 0xBB, 0xBF }; + /// - /// Determines if a file is binary based on its content. + /// Determines if a file is binary based on its content (I/O operation). + /// Returns false on any error to fail-safe to text processing. /// /// Path to the file to check. /// Number of bytes to read for analysis. @@ -16,26 +20,18 @@ public static bool IsBinaryFile(string filePath, int chunkSize = 4096, double bi { Guard.NotNullOrEmpty(filePath, nameof(filePath)); - if (!File.Exists(filePath)) - { - return false; - } + return File.Exists(filePath) && CheckFileBinaryContent(filePath, chunkSize, binaryThreshold); + } + /// + /// I/O operation: reads file and checks if content is binary. + /// + private static bool CheckFileBinaryContent(string filePath, int chunkSize, double threshold) + { try { using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - - if (stream.Length == 0) - { - return false; - } - - if (HasUtf8Bom(stream)) - { - return false; - } - - return CheckBinaryContent(stream, chunkSize, binaryThreshold); + return IsStreamBinary(stream, chunkSize, threshold); } catch { @@ -43,35 +39,52 @@ public static bool IsBinaryFile(string filePath, int chunkSize = 4096, double bi } } + /// + /// I/O operation: analyzes stream content to determine if binary. + /// Checks for UTF-8 BOM first, then analyzes byte patterns. + /// + private static bool IsStreamBinary(FileStream stream, int chunkSize, double threshold) => + stream.Length > 0 && !HasUtf8Bom(stream) && HasBinaryContent(stream, chunkSize, threshold); + + /// + /// I/O operation: checks if stream starts with UTF-8 BOM. + /// Resets stream position after checking (side effect). + /// private static bool HasUtf8Bom(FileStream stream) { - if (stream.Length < 3) + if (stream.Length < Utf8Bom.Length) { return false; } - var bom = new byte[3]; - stream.Read(bom, 0, 3); + var bom = new byte[Utf8Bom.Length]; + var bytesRead = stream.Read(bom, 0, Utf8Bom.Length); stream.Position = 0; - return bom[0] == 0xEF && bom[1] == 0xBB && bom[2] == 0xBF; + + return bytesRead == Utf8Bom.Length && bom.SequenceEqual(Utf8Bom); } - private static bool CheckBinaryContent(FileStream stream, int chunkSize, double threshold) + /// + /// I/O operation: checks if stream content exceeds binary threshold. + /// + private static bool HasBinaryContent(FileStream stream, int chunkSize, double threshold) { var buffer = new byte[chunkSize]; var bytesRead = stream.Read(buffer, 0, chunkSize); - if (bytesRead == 0) - { - return false; - } - - var nonPrintableCount = buffer.Take(bytesRead).Count(IsBinaryByte); - return (double)nonPrintableCount / bytesRead > threshold; + return bytesRead > 0 && CalculateBinaryRatio(buffer, bytesRead) > threshold; } - private static bool IsBinaryByte(byte b) - { - return b is (< 7 or > 14) and (< 32 or > 127); - } + /// + /// Pure function: calculates ratio of binary bytes in buffer. + /// + private static double CalculateBinaryRatio(byte[] buffer, int length) => + (double)buffer.Take(length).Count(IsBinaryByte) / length; + + /// + /// Pure predicate: determines if a byte is non-printable (binary). + /// Bytes outside printable ASCII range (32-127) except common control chars (7-14). + /// + private static bool IsBinaryByte(byte b) => + b is (< 7 or > 14) and (< 32 or > 127); } diff --git a/Utils/GitHelper.cs b/Utils/GitHelper.cs index 9452de9..ab8ed0b 100644 --- a/Utils/GitHelper.cs +++ b/Utils/GitHelper.cs @@ -1,42 +1,46 @@ namespace CodeContext.Utils; /// -/// Provides Git repository utilities. +/// Provides Git repository utilities using pure functional recursion. /// public static class GitHelper { /// /// Finds the root directory of the Git repository containing the specified path. + /// Uses tail-recursive search through parent directories. /// /// The path to start searching from. /// The Git repository root path, or null if not in a Git repository. - public static string? FindRepositoryRoot(string? startPath) - { - if (string.IsNullOrEmpty(startPath) || !Directory.Exists(startPath)) - { - return null; - } - - var currentPath = startPath; - while (!string.IsNullOrEmpty(currentPath)) - { - if (Directory.Exists(Path.Combine(currentPath, ".git"))) - { - return currentPath; - } - currentPath = Path.GetDirectoryName(currentPath); - } - - return null; - } + public static string? FindRepositoryRoot(string? startPath) => + string.IsNullOrEmpty(startPath) || !Directory.Exists(startPath) + ? null + : FindRepositoryRootRecursive(startPath); /// /// Determines if the specified path is within a Git repository. /// /// The path to check. /// True if the path is within a Git repository; otherwise, false. - public static bool IsInRepository(string? path) - { - return FindRepositoryRoot(path) != null; - } + public static bool IsInRepository(string? path) => + FindRepositoryRoot(path) != null; + + /// + /// Pure recursive function to find git repository root. + /// Walks up directory tree until .git folder is found or root is reached. + /// + private static string? FindRepositoryRootRecursive(string currentPath) => + HasGitDirectory(currentPath) + ? currentPath + : Path.GetDirectoryName(currentPath) switch + { + null => null, + var parent when string.IsNullOrEmpty(parent) => null, + var parent => FindRepositoryRootRecursive(parent) + }; + + /// + /// I/O operation: checks if a directory contains a .git subdirectory. + /// + private static bool HasGitDirectory(string path) => + Directory.Exists(Path.Combine(path, ".git")); }