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"));
}