diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..a6afbeb96 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,26 @@ +name: Build + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + +jobs: + build: + name: Build Solution + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Run build + run: | + chmod +x ./build.sh + ./build.sh diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 000000000..ad0a11c27 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,36 @@ +name: Test Coverage + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + +jobs: + coverage: + name: Check Test Coverage + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Run coverage check + run: | + chmod +x ./coverage.sh + ./coverage.sh 80 + env: + MIN_COVERAGE: 80 + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: TestResults/*/coverage.cobertura.xml + if-no-files-found: warn diff --git a/AGENTS.md b/AGENTS.md index 921b3b978..edc048659 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,25 @@ see @docs/AGENTS.md -Ignore the "archived" directory. \ No newline at end of file +Ignore the "archived" directory. + +# Definition of done + +- `format.sh` is passing without errors or warnings +- `build.sh` is passing without errors or warnings +- Test coverage is greater than 80% and `coverage.sh` is not failing +- Problems are not hidden, problems are addressed. + +# C# Code Style + +- Use .NET 10 and C# 14 +- Always use `this.` prefix +- Keep magic values and constants in a centralized `Constants.cs` file +- One class per file, matching the class name with the file name +- Sort class methods by visibility: public first, private at the end +- Sort class fields and const by visibility: private, const, props +- Keep all fields and consts at the top of classes +- Ensure dirs and paths logic is cross-platform compatible +- Avoid generic/meaningless names like "Utils" "Common" "Lib" +- Use plural for Enum names, e.g. "EmbeddingsTypes" +- Always use explicit visibility +- Don't use primary constructors \ No newline at end of file diff --git a/README.md b/README.md index f0634ae20..7c90f2b2d 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,15 @@ The previous codebase remains in the repo for reference only. Both versions exist purely as research projects used to explore new ideas and gather feedback from the community. +# What’s next + +An important aspect of KM² is how we are building the next memory prototype. In parallel, our team is developing [Amplifier](https://github.com/microsoft/amplifier/tree/next), a platform for metacognitive AI engineering. We use Amplifier to build Amplifier itself — and in the same way, we are using Amplifier to build the next generation of Kernel Memory. + +KM² will focus on the following areas, which will be documented in more detail when ready: +- quality of content generated +- privacy +- collaboration + ## Disclaimer > [!IMPORTANT] > **This is experimental software. _Expect things to break_.** diff --git a/build.sh b/build.sh new file mode 100755 index 000000000..a8e9ca2e4 --- /dev/null +++ b/build.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +set -e + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +cd $ROOT + +echo "=======================================" +echo " Building Kernel Memory Solution" +echo "=======================================" +echo "" + +# Clean previous build artifacts +echo "Cleaning previous build artifacts..." +dotnet clean --nologo --verbosity quiet +echo "✓ Clean complete" +echo "" + +# Restore dependencies +echo "Restoring dependencies..." +dotnet restore --nologo +echo "✓ Restore complete" +echo "" + +# Build solution with strict settings +echo "Building solution..." +echo "" + +# Build with: +# - TreatWarningsAsErrors: Fail on any warnings (compliance requirement) +# - EnforceCodeStyleInBuild: Enforce code style during build +# - NoWarn: Empty (don't suppress any warnings) +dotnet build \ + --no-restore \ + --configuration Release \ + /p:TreatWarningsAsErrors=true \ + /p:EnforceCodeStyleInBuild=true \ + /warnaserror + +BUILD_RESULT=$? + +echo "" + +if [ $BUILD_RESULT -eq 0 ]; then + echo "=======================================" + echo " ✅ Build Successful" + echo "=======================================" + echo "" + echo "All projects built successfully with zero warnings." + exit 0 +else + echo "=======================================" + echo " ❌ Build Failed" + echo "=======================================" + echo "" + echo "Build failed with errors or warnings." + echo "Review the output above for details." + echo "" + echo "Reminder: This project has zero-tolerance for warnings." + exit 1 +fi diff --git a/clean.sh b/clean.sh new file mode 100755 index 000000000..6661aba3b --- /dev/null +++ b/clean.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +set -e + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +cd $ROOT + +rm -rf TestResults + +rm -rf src/Core/bin +rm -rf src/Core/obj + +rm -rf src/Main/bin +rm -rf src/Main/obj diff --git a/coverage.sh b/coverage.sh new file mode 100755 index 000000000..ab879e3f9 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +set -e + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +cd $ROOT + +# Minimum coverage threshold - can be overridden via first argument +# Default: 80% (as specified in AGENTS.md) +MIN_COVERAGE=${1:-80} + +echo "Running tests with coverage collection..." +echo "" + +# Run tests with coverage using coverlet.collector +# --collect:"XPlat Code Coverage" enables the collector +# --results-directory specifies output location +dotnet test \ + --collect:"XPlat Code Coverage" \ + --results-directory ./TestResults + +echo "" +echo "Coverage collection complete!" +echo "" + +# Find the most recent coverage file (coverlet.collector creates it in a GUID subfolder) +COVERAGE_REPORT=$(find ./TestResults -name "coverage.cobertura.xml" | head -1) + +echo "Coverage report location: $COVERAGE_REPORT" +echo "" + +if [ -f "$COVERAGE_REPORT" ]; then + # Parse line coverage from cobertura XML + LINE_RATE=$(grep -o 'line-rate="[0-9.]*"' "$COVERAGE_REPORT" | head -1 | grep -o '[0-9.]*') + + if [ -n "$LINE_RATE" ]; then + # Convert to percentage + COVERAGE_PCT=$(awk "BEGIN {printf \"%.2f\", $LINE_RATE * 100}") + + echo "=====================================" + echo " Test Coverage: ${COVERAGE_PCT}%" + echo " Threshold: ${MIN_COVERAGE}%" + echo "=====================================" + echo "" + + # Check if coverage meets threshold + MEETS_THRESHOLD=$(awk "BEGIN {print ($COVERAGE_PCT >= $MIN_COVERAGE) ? 1 : 0}") + + if [ "$MEETS_THRESHOLD" -eq 0 ]; then + echo "❌ Coverage ${COVERAGE_PCT}% is below minimum threshold of ${MIN_COVERAGE}%" + exit 1 + else + echo "✅ Coverage meets minimum threshold" + rm -rf TestResults + fi + else + echo "⚠️ Could not parse coverage percentage from report" + fi +else + echo "⚠️ Coverage report not found at: $COVERAGE_REPORT" +fi diff --git a/mem.slnx b/mem.slnx index e0995332f..9f0b5fc0f 100644 --- a/mem.slnx +++ b/mem.slnx @@ -1,4 +1,7 @@  + + + \ No newline at end of file diff --git a/src/.editorconfig b/src/.editorconfig index a95dda356..96fcc6217 100644 --- a/src/.editorconfig +++ b/src/.editorconfig @@ -378,23 +378,25 @@ dotnet_naming_rule.async_methods_end_in_async.severity = error # Resharper # ##################################### -# disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract +# ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract resharper_Condition_Is_Always_True_Or_False_According_To_Nullable_API_Contract_highlighting = none - -# disable RedundantTypeArgumentsOfMethod +# RedundantTypeArgumentsOfMethod resharper_Redundant_Type_Arguments_Of_Method_highlighting = none - -# disable NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract +# NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract resharper_Null_Coalescing_Condition_Is_Always_Not_Null_According_To_API_Contract_highlighting = none - -# disable PartialTypeWithSinglePart +# PartialTypeWithSinglePart resharper_Partial_Type_With_Single_Part_highlighting = none - -# disable RedundantDefaultMemberInitializer +# RedundantDefaultMemberInitializer resharper_Redundant_Default_Member_Initializer_highlighting = none - -# disable ArrangeTypeModifiers +# ArrangeTypeModifiers resharper_Arrange_Type_Modifiers_highlighting = none - -# disable ArrangeTypeMemberModifiers +# ArrangeTypeMemberModifiers resharper_Arrange_Type_Member_Modifiers_highlighting = none +# InconsistentNaming +resharper_Inconsistent_Naming_highlighting = none + + +# CA1056: URI properties should not be strings +# Suppressed for config classes where string URLs are more practical for JSON serialization +dotnet_diagnostic.CA1056.severity = none + diff --git a/src/Core/Config/AppConfig.cs b/src/Core/Config/AppConfig.cs new file mode 100644 index 000000000..a2098b9b2 --- /dev/null +++ b/src/Core/Config/AppConfig.cs @@ -0,0 +1,79 @@ +using System.Text.Json.Serialization; +using KernelMemory.Core.Config.Cache; +using KernelMemory.Core.Config.Validation; + +namespace KernelMemory.Core.Config; + +/// +/// Root configuration for Kernel Memory application +/// Loaded from ~/.km/config.json or custom path +/// +public sealed class AppConfig : IValidatable +{ + /// + /// Named memory nodes (e.g., "personal", "work") + /// Key is the node ID, value is the node configuration + /// + [JsonPropertyName("nodes")] + public Dictionary Nodes { get; set; } = new(); + + /// + /// Optional cache for embeddings to reduce API calls + /// + [JsonPropertyName("embeddingsCache")] + public CacheConfig? EmbeddingsCache { get; set; } + + /// + /// Optional cache for LLM responses + /// + [JsonPropertyName("llmCache")] + public CacheConfig? LLMCache { get; set; } + + /// + /// Validates the entire configuration tree + /// + /// + public void Validate(string path = "") + { + if (this.Nodes.Count == 0) + { + throw new ConfigException("Nodes", "At least one node must be configured"); + } + + foreach (var (nodeId, nodeConfig) in this.Nodes) + { + if (string.IsNullOrWhiteSpace(nodeId)) + { + throw new ConfigException("Nodes", "Node ID cannot be empty"); + } + + nodeConfig.Validate($"Nodes.{nodeId}"); + } + + this.EmbeddingsCache?.Validate("EmbeddingsCache"); + this.LLMCache?.Validate("LLMCache"); + } + + /// + /// Creates a default configuration with a single "personal" node + /// using local SQLite storage + /// + public static AppConfig CreateDefault() + { + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var kmDir = Path.Combine(homeDir, ".km"); + var personalNodeDir = Path.Combine(kmDir, "nodes", "personal"); + + return new AppConfig + { + Nodes = new Dictionary + { + ["personal"] = NodeConfig.CreateDefaultPersonalNode(personalNodeDir) + }, + EmbeddingsCache = CacheConfig.CreateDefaultSqliteCache( + Path.Combine(kmDir, "embeddings-cache.db") + ), + LLMCache = null + }; + } +} diff --git a/src/Core/Config/Cache/CacheConfig.cs b/src/Core/Config/Cache/CacheConfig.cs new file mode 100644 index 000000000..a31305b6c --- /dev/null +++ b/src/Core/Config/Cache/CacheConfig.cs @@ -0,0 +1,88 @@ +using System.Text.Json.Serialization; +using KernelMemory.Core.Config.Enums; +using KernelMemory.Core.Config.Validation; + +namespace KernelMemory.Core.Config.Cache; + +/// +/// Cache configuration for embeddings or LLM responses +/// +public sealed class CacheConfig : IValidatable +{ + /// + /// Allow reading from cache + /// + [JsonPropertyName("allowRead")] + public bool AllowRead { get; set; } = true; + + /// + /// Allow writing to cache + /// + [JsonPropertyName("allowWrite")] + public bool AllowWrite { get; set; } = true; + + /// + /// Type of cache storage backend + /// + [JsonPropertyName("type")] + public CacheTypes Type { get; set; } = CacheTypes.Sqlite; + + /// + /// Path to SQLite database (for Sqlite cache) + /// Mutually exclusive with ConnectionString + /// + [JsonPropertyName("path")] + public string? Path { get; set; } + + /// + /// PostgreSQL connection string (for Postgres cache) + /// Mutually exclusive with Path + /// + [JsonPropertyName("connectionString")] + public string? ConnectionString { get; set; } + + /// + /// Validates the cache configuration + /// + /// + public void Validate(string path) + { + var isSqlite = this.Type == CacheTypes.Sqlite; + var isPostgres = this.Type == CacheTypes.Postgres; + var hasPath = !string.IsNullOrWhiteSpace(this.Path); + var hasConnectionString = !string.IsNullOrWhiteSpace(this.ConnectionString); + + if (isSqlite && !hasPath) + { + throw new ConfigException($"{path}.Path", "SQLite cache requires Path"); + } + + if (isPostgres && !hasConnectionString) + { + throw new ConfigException($"{path}.ConnectionString", + "PostgreSQL cache requires ConnectionString"); + } + + if (hasPath && hasConnectionString) + { + throw new ConfigException(path, + "Cache: specify either Path (SQLite) or ConnectionString (Postgres), not both"); + } + } + + /// + /// Creates a default SQLite cache configuration + /// + /// + internal static CacheConfig CreateDefaultSqliteCache(string path) + { + return new CacheConfig + { + AllowRead = true, + AllowWrite = true, + Type = CacheTypes.Sqlite, + Path = path, + ConnectionString = null + }; + } +} diff --git a/src/Core/Config/ConfigParser.cs b/src/Core/Config/ConfigParser.cs new file mode 100644 index 000000000..7db74bc76 --- /dev/null +++ b/src/Core/Config/ConfigParser.cs @@ -0,0 +1,253 @@ +using System.Text.Json; +using KernelMemory.Core.Config.Cache; +using KernelMemory.Core.Config.ContentIndex; +using KernelMemory.Core.Config.SearchIndex; +using KernelMemory.Core.Config.Storage; +using KernelMemory.Core.Config.Validation; + +namespace KernelMemory.Core.Config; + +/// +/// Parses configuration files and returns validated AppConfig instances +/// Supports JSON with comments and case-insensitive property names +/// +public static class ConfigParser +{ + /// + /// JSON serializer options configured for config parsing + /// - Case insensitive property names + /// - Comments allowed + /// - Trailing commas allowed + /// + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + /// + /// Loads configuration from a file, or returns default config if file doesn't exist + /// Performs tilde expansion on paths (~/ → home directory) + /// + /// Path to configuration file + /// Validated AppConfig instance + /// Thrown when file exists but parsing or validation fails + public static AppConfig LoadFromFile(string filePath) + { + // If file doesn't exist, return default configuration + if (!File.Exists(filePath)) + { + return AppConfig.CreateDefault(); + } + + try + { + // Read file content + var json = File.ReadAllText(filePath); + + // Parse and validate + var config = ParseFromString(json); + + // Expand tilde paths + ExpandTildePaths(config); + + return config; + } + catch (ConfigException) + { + // Re-throw configuration exceptions as-is + throw; + } + catch (JsonException ex) + { + throw new ConfigException( + filePath, + $"Failed to parse configuration file: {ex.Message}", + ex); + } + catch (Exception ex) + { + throw new ConfigException( + filePath, + $"Error reading configuration file: {ex.Message}", + ex); + } + } + + /// + /// Parses configuration from a JSON string + /// + /// JSON configuration string + /// Validated AppConfig instance + /// Thrown when parsing or validation fails + public static AppConfig ParseFromString(string json) + { + try + { + var config = JsonSerializer.Deserialize(json, s_jsonOptions); + + if (config == null) + { + throw new ConfigException( + "root", + "Failed to deserialize configuration: result was null"); + } + + // Validate the configuration + config.Validate(); + + return config; + } + catch (ConfigException) + { + // Re-throw configuration exceptions as-is + throw; + } + catch (JsonException ex) + { + throw new ConfigException( + "root", + $"Failed to parse configuration: {ex.Message}", + ex); + } + } + + /// + /// Expands tilde (~) in paths to the user's home directory + /// Recursively processes all path properties in the configuration + /// + /// + private static void ExpandTildePaths(AppConfig config) + { + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + foreach (var (_, nodeConfig) in config.Nodes) + { + // Expand paths in ContentIndex + ExpandTildeInContentIndex(nodeConfig.ContentIndex, homeDir); + + // Expand paths in FileStorage + if (nodeConfig.FileStorage != null) + { + ExpandTildeInStorage(nodeConfig.FileStorage, homeDir); + } + + // Expand paths in RepoStorage + if (nodeConfig.RepoStorage != null) + { + ExpandTildeInStorage(nodeConfig.RepoStorage, homeDir); + } + + // Expand paths in SearchIndexes + foreach (var searchIndex in nodeConfig.SearchIndexes) + { + ExpandTildeInSearchIndex(searchIndex, homeDir); + } + } + + // Expand paths in EmbeddingsCache + if (config.EmbeddingsCache != null) + { + ExpandTildeInCache(config.EmbeddingsCache, homeDir); + } + + // Expand paths in LLMCache + if (config.LLMCache != null) + { + ExpandTildeInCache(config.LLMCache, homeDir); + } + } + + /// + /// Expands tilde in ContentIndex paths + /// + /// + /// + private static void ExpandTildeInContentIndex(ContentIndexConfig config, string homeDir) + { + if (config is SqliteContentIndexConfig sqliteConfig) + { + sqliteConfig.Path = ExpandTilde(sqliteConfig.Path, homeDir); + } + } + + /// + /// Expands tilde in Storage paths + /// + /// + /// + private static void ExpandTildeInStorage(StorageConfig config, string homeDir) + { + if (config is DiskStorageConfig diskConfig) + { + diskConfig.Path = ExpandTilde(diskConfig.Path, homeDir); + } + } + + /// + /// Expands tilde in SearchIndex paths + /// + /// + /// + private static void ExpandTildeInSearchIndex(SearchIndexConfig config, string homeDir) + { + switch (config) + { + case FtsSearchIndexConfig ftsConfig when ftsConfig.Path != null: + ftsConfig.Path = ExpandTilde(ftsConfig.Path, homeDir); + break; + case VectorSearchIndexConfig vectorConfig when vectorConfig.Path != null: + vectorConfig.Path = ExpandTilde(vectorConfig.Path, homeDir); + break; + case GraphSearchIndexConfig graphConfig when graphConfig.Path != null: + graphConfig.Path = ExpandTilde(graphConfig.Path, homeDir); + break; + } + } + + /// + /// Expands tilde in Cache paths + /// + /// + /// + private static void ExpandTildeInCache(CacheConfig config, string homeDir) + { + if (config.Path != null) + { + config.Path = ExpandTilde(config.Path, homeDir); + } + } + + /// + /// Expands tilde (~/ or ~\) at the start of a path to the home directory. + /// Cross-platform: handles both forward slash (Unix/macOS) and backslash (Windows). + /// + /// + /// + private static string ExpandTilde(string path, string homeDir) + { + if (string.IsNullOrWhiteSpace(path)) + { + return path; + } + + // Handle just ~ (home directory) + if (path == "~") + { + return homeDir; + } + + // Handle ~/ or ~\ (home directory with path separator) + // Cross-platform: works with both / (Unix) and \ (Windows) + if (path.Length >= 2 && path[0] == '~' && + (path[1] == Path.DirectorySeparatorChar || + path[1] == Path.AltDirectorySeparatorChar)) + { + return Path.Combine(homeDir, path.Substring(2)); + } + + return path; + } +} diff --git a/src/Core/Config/ContentIndex/ContentIndexConfig.cs b/src/Core/Config/ContentIndex/ContentIndexConfig.cs new file mode 100644 index 000000000..10b6df46e --- /dev/null +++ b/src/Core/Config/ContentIndex/ContentIndexConfig.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; +using KernelMemory.Core.Config.Enums; +using KernelMemory.Core.Config.Validation; + +namespace KernelMemory.Core.Config.ContentIndex; + +/// +/// Base class for content index configurations +/// Content index is the source of truth, backed by Entity Framework +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(SqliteContentIndexConfig), typeDiscriminator: "sqlite")] +[JsonDerivedType(typeof(PostgresContentIndexConfig), typeDiscriminator: "postgres")] +public abstract class ContentIndexConfig : IValidatable +{ + /// + /// Type of content index + /// + [JsonIgnore] + public abstract ContentIndexTypes Type { get; } + + /// + /// Validates the content index configuration + /// + /// + public abstract void Validate(string path); +} diff --git a/src/Core/Config/ContentIndex/PostgresContentIndexConfig.cs b/src/Core/Config/ContentIndex/PostgresContentIndexConfig.cs new file mode 100644 index 000000000..811703b0c --- /dev/null +++ b/src/Core/Config/ContentIndex/PostgresContentIndexConfig.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; +using KernelMemory.Core.Config.Enums; +using KernelMemory.Core.Config.Validation; + +namespace KernelMemory.Core.Config.ContentIndex; + +/// +/// PostgreSQL content index configuration +/// +public sealed class PostgresContentIndexConfig : ContentIndexConfig +{ + /// + [JsonIgnore] + public override ContentIndexTypes Type => ContentIndexTypes.Postgres; + + /// + /// PostgreSQL connection string + /// Can be overridden by Aspire environment variables + /// + [JsonPropertyName("connectionString")] + public string ConnectionString { get; set; } = string.Empty; + + /// + public override void Validate(string path) + { + if (string.IsNullOrWhiteSpace(this.ConnectionString)) + { + throw new ConfigException($"{path}.ConnectionString", + "PostgreSQL connection string is required"); + } + } +} diff --git a/src/Core/Config/ContentIndex/SqliteContentIndexConfig.cs b/src/Core/Config/ContentIndex/SqliteContentIndexConfig.cs new file mode 100644 index 000000000..90e9d1831 --- /dev/null +++ b/src/Core/Config/ContentIndex/SqliteContentIndexConfig.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; +using KernelMemory.Core.Config.Enums; +using KernelMemory.Core.Config.Validation; + +namespace KernelMemory.Core.Config.ContentIndex; + +/// +/// SQLite content index configuration +/// +public sealed class SqliteContentIndexConfig : ContentIndexConfig +{ + /// + [JsonIgnore] + public override ContentIndexTypes Type => ContentIndexTypes.Sqlite; + + /// + /// Path to SQLite database file (supports tilde expansion) + /// + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; + + /// + public override void Validate(string path) + { + if (string.IsNullOrWhiteSpace(this.Path)) + { + throw new ConfigException($"{path}.Path", "SQLite path is required"); + } + + // Path will be expanded and validated by the config loader + } +} diff --git a/src/Core/Config/Embeddings/AzureOpenAIEmbeddingsConfig.cs b/src/Core/Config/Embeddings/AzureOpenAIEmbeddingsConfig.cs new file mode 100644 index 000000000..5bf10ec3d --- /dev/null +++ b/src/Core/Config/Embeddings/AzureOpenAIEmbeddingsConfig.cs @@ -0,0 +1,84 @@ +using System.Text.Json.Serialization; +using KernelMemory.Core.Config.Enums; +using KernelMemory.Core.Config.Validation; + +namespace KernelMemory.Core.Config.Embeddings; + +/// +/// Azure OpenAI embeddings provider configuration +/// +public sealed class AzureOpenAIEmbeddingsConfig : EmbeddingsConfig +{ + /// + [JsonIgnore] + public override EmbeddingsTypes Type => EmbeddingsTypes.AzureOpenAI; + + /// + /// Model name (e.g., "text-embedding-ada-002") + /// + [JsonPropertyName("model")] + public string Model { get; set; } = string.Empty; + + /// + /// Azure OpenAI endpoint (e.g., "https://myservice.openai.azure.com/") + /// + [JsonPropertyName("endpoint")] + public string Endpoint { get; set; } = string.Empty; + + /// + /// Azure OpenAI API key (optional if using managed identity) + /// + [JsonPropertyName("apiKey")] + public string? ApiKey { get; set; } + + /// + /// Deployment name in Azure OpenAI + /// + [JsonPropertyName("deployment")] + public string Deployment { get; set; } = string.Empty; + + /// + /// Use Azure Managed Identity for authentication + /// + [JsonPropertyName("useManagedIdentity")] + public bool UseManagedIdentity { get; set; } + + /// + public override void Validate(string path) + { + if (string.IsNullOrWhiteSpace(this.Model)) + { + throw new ConfigException($"{path}.Model", "Model name is required"); + } + + if (string.IsNullOrWhiteSpace(this.Endpoint)) + { + throw new ConfigException($"{path}.Endpoint", "Azure OpenAI endpoint is required"); + } + + if (!Uri.TryCreate(this.Endpoint, UriKind.Absolute, out _)) + { + throw new ConfigException($"{path}.Endpoint", + $"Invalid Azure OpenAI endpoint: {this.Endpoint}"); + } + + if (string.IsNullOrWhiteSpace(this.Deployment)) + { + throw new ConfigException($"{path}.Deployment", "Deployment name is required"); + } + + var hasApiKey = !string.IsNullOrWhiteSpace(this.ApiKey); + + if (!hasApiKey && !this.UseManagedIdentity) + { + throw new ConfigException(path, + "Azure OpenAI requires either ApiKey or UseManagedIdentity"); + } + + if (hasApiKey && this.UseManagedIdentity) + { + throw new ConfigException(path, + "Azure OpenAI: specify either ApiKey or UseManagedIdentity, not both"); + } + } +} diff --git a/src/Core/Config/Embeddings/EmbeddingsConfig.cs b/src/Core/Config/Embeddings/EmbeddingsConfig.cs new file mode 100644 index 000000000..88f205252 --- /dev/null +++ b/src/Core/Config/Embeddings/EmbeddingsConfig.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; +using KernelMemory.Core.Config.Enums; +using KernelMemory.Core.Config.Validation; + +namespace KernelMemory.Core.Config.Embeddings; + +/// +/// Base class for embeddings provider configurations +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(OllamaEmbeddingsConfig), typeDiscriminator: "ollama")] +[JsonDerivedType(typeof(OpenAIEmbeddingsConfig), typeDiscriminator: "openai")] +[JsonDerivedType(typeof(AzureOpenAIEmbeddingsConfig), typeDiscriminator: "azureOpenAI")] +public abstract class EmbeddingsConfig : IValidatable +{ + /// + /// Type of embeddings provider + /// + [JsonIgnore] + public abstract EmbeddingsTypes Type { get; } + + /// + /// Validates the embeddings configuration + /// + /// + public abstract void Validate(string path); +} diff --git a/src/Core/Config/Embeddings/OllamaEmbeddingsConfig.cs b/src/Core/Config/Embeddings/OllamaEmbeddingsConfig.cs new file mode 100644 index 000000000..2fcb36dad --- /dev/null +++ b/src/Core/Config/Embeddings/OllamaEmbeddingsConfig.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Serialization; +using KernelMemory.Core.Config.Enums; +using KernelMemory.Core.Config.Validation; + +namespace KernelMemory.Core.Config.Embeddings; + +/// +/// Ollama embeddings provider configuration +/// +public sealed class OllamaEmbeddingsConfig : EmbeddingsConfig +{ + /// + [JsonIgnore] + public override EmbeddingsTypes Type => EmbeddingsTypes.Ollama; + + /// + /// Ollama model name (e.g., "nomic-embed-text", "mxbai-embed-large") + /// + [JsonPropertyName("model")] + public string Model { get; set; } = string.Empty; + + /// + /// Ollama base URL (e.g., "http://localhost:11434") + /// + [JsonPropertyName("baseUrl")] + public string BaseUrl { get; set; } = "http://localhost:11434"; + + /// + public override void Validate(string path) + { + if (string.IsNullOrWhiteSpace(this.Model)) + { + throw new ConfigException($"{path}.Model", "Ollama model name is required"); + } + + if (string.IsNullOrWhiteSpace(this.BaseUrl)) + { + throw new ConfigException($"{path}.BaseUrl", "Ollama base URL is required"); + } + + if (!Uri.TryCreate(this.BaseUrl, UriKind.Absolute, out _)) + { + throw new ConfigException($"{path}.BaseUrl", + $"Invalid Ollama base URL: {this.BaseUrl}"); + } + } +} diff --git a/src/Core/Config/Embeddings/OpenAIEmbeddingsConfig.cs b/src/Core/Config/Embeddings/OpenAIEmbeddingsConfig.cs new file mode 100644 index 000000000..9f73966fc --- /dev/null +++ b/src/Core/Config/Embeddings/OpenAIEmbeddingsConfig.cs @@ -0,0 +1,54 @@ +using System.Text.Json.Serialization; +using KernelMemory.Core.Config.Enums; +using KernelMemory.Core.Config.Validation; + +namespace KernelMemory.Core.Config.Embeddings; + +/// +/// OpenAI embeddings provider configuration +/// +public sealed class OpenAIEmbeddingsConfig : EmbeddingsConfig +{ + /// + [JsonIgnore] + public override EmbeddingsTypes Type => EmbeddingsTypes.OpenAI; + + /// + /// OpenAI model name (e.g., "text-embedding-ada-002", "text-embedding-3-small") + /// + [JsonPropertyName("model")] + public string Model { get; set; } = string.Empty; + + /// + /// OpenAI API key + /// + [JsonPropertyName("apiKey")] + public string ApiKey { get; set; } = string.Empty; + + /// + /// Optional custom base URL (for OpenAI-compatible APIs) + /// + [JsonPropertyName("baseUrl")] + public string? BaseUrl { get; set; } + + /// + public override void Validate(string path) + { + if (string.IsNullOrWhiteSpace(this.Model)) + { + throw new ConfigException($"{path}.Model", "OpenAI model name is required"); + } + + if (string.IsNullOrWhiteSpace(this.ApiKey)) + { + throw new ConfigException($"{path}.ApiKey", "OpenAI API key is required"); + } + + if (!string.IsNullOrWhiteSpace(this.BaseUrl) && + !Uri.TryCreate(this.BaseUrl, UriKind.Absolute, out _)) + { + throw new ConfigException($"{path}.BaseUrl", + $"Invalid OpenAI base URL: {this.BaseUrl}"); + } + } +} diff --git a/src/Core/Config/Enums/CacheTypes.cs b/src/Core/Config/Enums/CacheTypes.cs new file mode 100644 index 000000000..501b51049 --- /dev/null +++ b/src/Core/Config/Enums/CacheTypes.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace KernelMemory.Core.Config.Enums; + +/// +/// Type of cache storage backend +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CacheTypes +{ + /// SQLite database cache + Sqlite, + + /// PostgreSQL database cache + Postgres +} diff --git a/src/Core/Config/Enums/ContentIndexTypes.cs b/src/Core/Config/Enums/ContentIndexTypes.cs new file mode 100644 index 000000000..4780f84af --- /dev/null +++ b/src/Core/Config/Enums/ContentIndexTypes.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace KernelMemory.Core.Config.Enums; + +/// +/// Type of Entity Framework backed content index +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ContentIndexTypes +{ + /// SQLite database for local/single-user scenarios + Sqlite, + + /// PostgreSQL database for multi-user/production scenarios + Postgres +} diff --git a/src/Core/Config/Enums/EmbeddingsTypes.cs b/src/Core/Config/Enums/EmbeddingsTypes.cs new file mode 100644 index 000000000..5421d6313 --- /dev/null +++ b/src/Core/Config/Enums/EmbeddingsTypes.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace KernelMemory.Core.Config.Enums; + +/// +/// Type of embeddings provider +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum EmbeddingsTypes +{ + /// Local Ollama instance + Ollama, + + /// OpenAI API + OpenAI, + + /// Azure OpenAI Service + AzureOpenAI +} diff --git a/src/Core/Config/Enums/NodeAccessLevels.cs b/src/Core/Config/Enums/NodeAccessLevels.cs new file mode 100644 index 000000000..ba0183c1d --- /dev/null +++ b/src/Core/Config/Enums/NodeAccessLevels.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace KernelMemory.Core.Config.Enums; + +/// +/// Defines the access level for a memory node +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum NodeAccessLevels +{ + /// Full read/write access + Full, + + /// Read-only access, no modifications allowed + ReadOnly, + + /// Write-only access, typically for ingestion pipelines + WriteOnly, + + /// Node is disabled and not accessible + Disabled +} diff --git a/src/Core/Config/Enums/SearchIndexTypes.cs b/src/Core/Config/Enums/SearchIndexTypes.cs new file mode 100644 index 000000000..abfaf9ca1 --- /dev/null +++ b/src/Core/Config/Enums/SearchIndexTypes.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +namespace KernelMemory.Core.Config.Enums; + +/// +/// Type of search index for content retrieval +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SearchIndexTypes +{ + /// SQLite Full-Text Search (FTS5) + SqliteFTS, + + /// PostgreSQL Full-Text Search + PostgresFTS, + + /// SQLite with vector extensions (sqlite-vec) + SqliteVector, + + /// PostgreSQL with pgvector extension + PostgresVector, + + /// Graph-based semantic search + Graph +} diff --git a/src/Core/Config/Enums/StorageTypes.cs b/src/Core/Config/Enums/StorageTypes.cs new file mode 100644 index 000000000..d32177b17 --- /dev/null +++ b/src/Core/Config/Enums/StorageTypes.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace KernelMemory.Core.Config.Enums; + +/// +/// Type of binary storage backend +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum StorageTypes +{ + /// Local filesystem storage + Disk, + + /// Azure Blob Storage + AzureBlobs +} diff --git a/src/Core/Config/Enums/VectorMetrics.cs b/src/Core/Config/Enums/VectorMetrics.cs new file mode 100644 index 000000000..5b84c00fb --- /dev/null +++ b/src/Core/Config/Enums/VectorMetrics.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace KernelMemory.Core.Config.Enums; + +/// +/// Distance/similarity metric for vector search +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum VectorMetrics +{ + /// Cosine similarity (normalized dot product) + Cosine, + + /// Euclidean distance (L2) + L2, + + /// Inner product (dot product) + InnerProduct, + + /// Manhattan distance (L1) + L1 +} diff --git a/src/Core/Config/NodeConfig.cs b/src/Core/Config/NodeConfig.cs new file mode 100644 index 000000000..53497eaed --- /dev/null +++ b/src/Core/Config/NodeConfig.cs @@ -0,0 +1,113 @@ +using System.Text.Json.Serialization; +using KernelMemory.Core.Config.ContentIndex; +using KernelMemory.Core.Config.Enums; +using KernelMemory.Core.Config.SearchIndex; +using KernelMemory.Core.Config.Storage; +using KernelMemory.Core.Config.Validation; + +namespace KernelMemory.Core.Config; + +/// +/// Configuration for a single memory node +/// +public sealed class NodeConfig : IValidatable +{ + /// + /// Unique identifier for this node + /// + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// + /// Access level for this node + /// + [JsonPropertyName("access")] + public NodeAccessLevels Access { get; set; } = NodeAccessLevels.Full; + + /// + /// Content index (source of truth) - REQUIRED + /// Stores metadata, cached content, and ingestion state + /// + [JsonPropertyName("contentIndex")] + public ContentIndexConfig ContentIndex { get; set; } = null!; + + /// + /// Optional file storage for binary files + /// + [JsonPropertyName("fileStorage")] + public StorageConfig? FileStorage { get; set; } + + /// + /// Optional repository storage for git repositories + /// + [JsonPropertyName("repoStorage")] + public StorageConfig? RepoStorage { get; set; } + + /// + /// Search indexes for content retrieval + /// Multiple indexes can be used for different search strategies + /// + [JsonPropertyName("searchIndexes")] + public List SearchIndexes { get; set; } = new(); + + /// + /// Validates the node configuration + /// + /// + public void Validate(string path) + { + if (string.IsNullOrWhiteSpace(this.Id)) + { + throw new ConfigException(path, "Node ID is required"); + } + + if (this.ContentIndex == null) + { + throw new ConfigException($"{path}.ContentIndex", "ContentIndex is required"); + } + + this.ContentIndex.Validate($"{path}.ContentIndex"); + this.FileStorage?.Validate($"{path}.FileStorage"); + this.RepoStorage?.Validate($"{path}.RepoStorage"); + + for (int i = 0; i < this.SearchIndexes.Count; i++) + { + this.SearchIndexes[i].Validate($"{path}.SearchIndexes[{i}]"); + } + } + + /// + /// Creates a default "personal" node configuration + /// + /// + internal static NodeConfig CreateDefaultPersonalNode(string nodeDir) + { + return new NodeConfig + { + Id = "personal", + Access = NodeAccessLevels.Full, + ContentIndex = new SqliteContentIndexConfig + { + Path = Path.Combine(nodeDir, "content.db") + }, + FileStorage = null, + RepoStorage = null, + SearchIndexes = new List + { + new FtsSearchIndexConfig + { + Type = SearchIndexTypes.SqliteFTS, + Path = Path.Combine(nodeDir, "fts.db"), + EnableStemming = true + }, + new VectorSearchIndexConfig + { + Type = SearchIndexTypes.SqliteVector, + Path = Path.Combine(nodeDir, "vectors.db"), + Dimensions = 768, + Metric = VectorMetrics.Cosine + } + } + }; + } +} diff --git a/src/Core/Config/SearchIndex/FtsSearchIndexConfig.cs b/src/Core/Config/SearchIndex/FtsSearchIndexConfig.cs new file mode 100644 index 000000000..95b10b7f0 --- /dev/null +++ b/src/Core/Config/SearchIndex/FtsSearchIndexConfig.cs @@ -0,0 +1,59 @@ +using System.Text.Json.Serialization; +using KernelMemory.Core.Config.Enums; +using KernelMemory.Core.Config.Validation; + +namespace KernelMemory.Core.Config.SearchIndex; + +/// +/// Full-Text Search index configuration (SQLite FTS5 or PostgreSQL) +/// +public sealed class FtsSearchIndexConfig : SearchIndexConfig +{ + /// + /// Path to SQLite database (for SqliteFTS) + /// Mutually exclusive with ConnectionString + /// + [JsonPropertyName("path")] + public string? Path { get; set; } + + /// + /// PostgreSQL connection string (for PostgresFTS) + /// Mutually exclusive with Path + /// + [JsonPropertyName("connectionString")] + public string? ConnectionString { get; set; } + + /// + /// Enable stemming for better search results + /// + [JsonPropertyName("enableStemming")] + public bool EnableStemming { get; set; } = true; + + /// + public override void Validate(string path) + { + this.Embeddings?.Validate($"{path}.Embeddings"); + + var isSqlite = this.Type == SearchIndexTypes.SqliteFTS; + var isPostgres = this.Type == SearchIndexTypes.PostgresFTS; + var hasPath = !string.IsNullOrWhiteSpace(this.Path); + var hasConnectionString = !string.IsNullOrWhiteSpace(this.ConnectionString); + + if (isSqlite && !hasPath) + { + throw new ConfigException($"{path}.Path", "SQLite FTS requires Path"); + } + + if (isPostgres && !hasConnectionString) + { + throw new ConfigException($"{path}.ConnectionString", + "PostgreSQL FTS requires ConnectionString"); + } + + if (hasPath && hasConnectionString) + { + throw new ConfigException(path, + "FTS index: specify either Path (SQLite) or ConnectionString (Postgres), not both"); + } + } +} diff --git a/src/Core/Config/SearchIndex/GraphSearchIndexConfig.cs b/src/Core/Config/SearchIndex/GraphSearchIndexConfig.cs new file mode 100644 index 000000000..272788cfb --- /dev/null +++ b/src/Core/Config/SearchIndex/GraphSearchIndexConfig.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; +using KernelMemory.Core.Config.Validation; + +namespace KernelMemory.Core.Config.SearchIndex; + +/// +/// Graph-based search index configuration +/// Uses recursive CTEs for SQLite or Apache AGE for PostgreSQL +/// +public sealed class GraphSearchIndexConfig : SearchIndexConfig +{ + /// + /// Path to SQLite database (for SQLite graph) + /// Mutually exclusive with ConnectionString + /// + [JsonPropertyName("path")] + public string? Path { get; set; } + + /// + /// PostgreSQL connection string (for Apache AGE graph) + /// Mutually exclusive with Path + /// + [JsonPropertyName("connectionString")] + public string? ConnectionString { get; set; } + + /// + public override void Validate(string path) + { + this.Embeddings?.Validate($"{path}.Embeddings"); + + var hasPath = !string.IsNullOrWhiteSpace(this.Path); + var hasConnectionString = !string.IsNullOrWhiteSpace(this.ConnectionString); + + if (!hasPath && !hasConnectionString) + { + throw new ConfigException(path, + "Graph index requires either Path (SQLite) or ConnectionString (Postgres)"); + } + + if (hasPath && hasConnectionString) + { + throw new ConfigException(path, + "Graph index: specify either Path or ConnectionString, not both"); + } + } +} diff --git a/src/Core/Config/SearchIndex/SearchIndexConfig.cs b/src/Core/Config/SearchIndex/SearchIndexConfig.cs new file mode 100644 index 000000000..a939116e3 --- /dev/null +++ b/src/Core/Config/SearchIndex/SearchIndexConfig.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; +using KernelMemory.Core.Config.Embeddings; +using KernelMemory.Core.Config.Enums; +using KernelMemory.Core.Config.Validation; + +namespace KernelMemory.Core.Config.SearchIndex; + +/// +/// Base class for search index configurations +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(FtsSearchIndexConfig), typeDiscriminator: "fts")] +[JsonDerivedType(typeof(VectorSearchIndexConfig), typeDiscriminator: "vector")] +[JsonDerivedType(typeof(GraphSearchIndexConfig), typeDiscriminator: "graph")] +public abstract class SearchIndexConfig : IValidatable +{ + /// + /// Type of search index + /// + [JsonIgnore] + public SearchIndexTypes Type { get; set; } + + /// + /// Optional embeddings configuration for this index + /// Overrides node-level or global embeddings config + /// + [JsonPropertyName("embeddings")] + public EmbeddingsConfig? Embeddings { get; set; } + + /// + /// Validates the search index configuration + /// + /// + public abstract void Validate(string path); +} diff --git a/src/Core/Config/SearchIndex/VectorSearchIndexConfig.cs b/src/Core/Config/SearchIndex/VectorSearchIndexConfig.cs new file mode 100644 index 000000000..bd6f23baf --- /dev/null +++ b/src/Core/Config/SearchIndex/VectorSearchIndexConfig.cs @@ -0,0 +1,80 @@ +using System.Text.Json.Serialization; +using KernelMemory.Core.Config.Enums; +using KernelMemory.Core.Config.Validation; + +namespace KernelMemory.Core.Config.SearchIndex; + +/// +/// Vector search index configuration (SQLite with sqlite-vec or PostgreSQL with pgvector) +/// +public sealed class VectorSearchIndexConfig : SearchIndexConfig +{ + /// + /// Path to SQLite database (for SqliteVector) + /// Mutually exclusive with ConnectionString + /// + [JsonPropertyName("path")] + public string? Path { get; set; } + + /// + /// PostgreSQL connection string (for PostgresVector) + /// Mutually exclusive with Path + /// + [JsonPropertyName("connectionString")] + public string? ConnectionString { get; set; } + + /// + /// Vector dimensions (must match embeddings model) + /// Common values: 384 (MiniLM), 768 (BERT), 1536 (OpenAI ada-002), 3072 (OpenAI ada-003) + /// + [JsonPropertyName("dimensions")] + public int Dimensions { get; set; } = 768; + + /// + /// Distance/similarity metric for vector comparison + /// + [JsonPropertyName("metric")] + public VectorMetrics Metric { get; set; } = VectorMetrics.Cosine; + + /// + public override void Validate(string path) + { + this.Embeddings?.Validate($"{path}.Embeddings"); + + var isSqlite = this.Type == SearchIndexTypes.SqliteVector; + var isPostgres = this.Type == SearchIndexTypes.PostgresVector; + var hasPath = !string.IsNullOrWhiteSpace(this.Path); + var hasConnectionString = !string.IsNullOrWhiteSpace(this.ConnectionString); + + if (isSqlite && !hasPath) + { + throw new ConfigException($"{path}.Path", "SQLite vector index requires Path"); + } + + if (isPostgres && !hasConnectionString) + { + throw new ConfigException($"{path}.ConnectionString", + "PostgreSQL vector index requires ConnectionString"); + } + + if (hasPath && hasConnectionString) + { + throw new ConfigException(path, + "Vector index: specify either Path (SQLite) or ConnectionString (Postgres), not both"); + } + + if (this.Dimensions <= 0) + { + throw new ConfigException($"{path}.Dimensions", + $"Vector dimensions must be positive (got {this.Dimensions})"); + } + + // Common dimensions check (warning, not error) + var commonDimensions = new[] { 384, 768, 1024, 1536, 3072 }; + if (!commonDimensions.Contains(this.Dimensions)) + { + // Log warning: uncommon dimension size + // This is acceptable, just informational + } + } +} diff --git a/src/Core/Config/Storage/AzureBlobStorageConfig.cs b/src/Core/Config/Storage/AzureBlobStorageConfig.cs new file mode 100644 index 000000000..e02590fd8 --- /dev/null +++ b/src/Core/Config/Storage/AzureBlobStorageConfig.cs @@ -0,0 +1,52 @@ +using System.Text.Json.Serialization; +using KernelMemory.Core.Config.Enums; +using KernelMemory.Core.Config.Validation; + +namespace KernelMemory.Core.Config.Storage; + +/// +/// Azure Blob Storage configuration +/// +public sealed class AzureBlobStorageConfig : StorageConfig +{ + /// + [JsonIgnore] + public override StorageTypes Type => StorageTypes.AzureBlobs; + + /// + /// Azure Storage connection string + /// + [JsonPropertyName("connectionString")] + public string? ConnectionString { get; set; } + + /// + /// Azure Storage API key (alternative to connection string) + /// + [JsonPropertyName("apiKey")] + public string? ApiKey { get; set; } + + /// + /// Use Azure Managed Identity for authentication + /// + [JsonPropertyName("useManagedIdentity")] + public bool UseManagedIdentity { get; set; } + + /// + public override void Validate(string path) + { + var hasConnectionString = !string.IsNullOrWhiteSpace(this.ConnectionString); + var hasApiKey = !string.IsNullOrWhiteSpace(this.ApiKey); + + if (!hasConnectionString && !hasApiKey && !this.UseManagedIdentity) + { + throw new ConfigException(path, + "Azure Blob storage requires one of: ConnectionString, ApiKey, or UseManagedIdentity"); + } + + if ((hasConnectionString ? 1 : 0) + (hasApiKey ? 1 : 0) + (this.UseManagedIdentity ? 1 : 0) > 1) + { + throw new ConfigException(path, + "Azure Blob storage: specify only one authentication method"); + } + } +} diff --git a/src/Core/Config/Storage/DiskStorageConfig.cs b/src/Core/Config/Storage/DiskStorageConfig.cs new file mode 100644 index 000000000..d4e7a900f --- /dev/null +++ b/src/Core/Config/Storage/DiskStorageConfig.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; +using KernelMemory.Core.Config.Enums; +using KernelMemory.Core.Config.Validation; + +namespace KernelMemory.Core.Config.Storage; + +/// +/// Local disk storage configuration +/// +public sealed class DiskStorageConfig : StorageConfig +{ + /// + [JsonIgnore] + public override StorageTypes Type => StorageTypes.Disk; + + /// + /// Path to storage directory (supports tilde expansion) + /// + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; + + /// + public override void Validate(string path) + { + if (string.IsNullOrWhiteSpace(this.Path)) + { + throw new ConfigException($"{path}.Path", "Disk storage path is required"); + } + } +} diff --git a/src/Core/Config/Storage/StorageConfig.cs b/src/Core/Config/Storage/StorageConfig.cs new file mode 100644 index 000000000..69b5ac9e9 --- /dev/null +++ b/src/Core/Config/Storage/StorageConfig.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; +using KernelMemory.Core.Config.Enums; +using KernelMemory.Core.Config.Validation; + +namespace KernelMemory.Core.Config.Storage; + +/// +/// Base class for storage configurations (files, repositories) +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(DiskStorageConfig), typeDiscriminator: "disk")] +[JsonDerivedType(typeof(AzureBlobStorageConfig), typeDiscriminator: "azureBlobs")] +public abstract class StorageConfig : IValidatable +{ + /// + /// Type of storage backend + /// + [JsonIgnore] + public abstract StorageTypes Type { get; } + + /// + /// Validates the storage configuration + /// + /// + public abstract void Validate(string path); +} diff --git a/src/Core/Config/Validation/ConfigException.cs b/src/Core/Config/Validation/ConfigException.cs new file mode 100644 index 000000000..8c4ccbb6c --- /dev/null +++ b/src/Core/Config/Validation/ConfigException.cs @@ -0,0 +1,50 @@ +namespace KernelMemory.Core.Config.Validation; + +/// +/// Exception thrown when configuration validation fails +/// +public class ConfigException : Exception +{ + /// + /// JSON path where the error occurred + /// + public string ConfigPath { get; } + + /// + /// Creates a new configuration exception + /// + /// JSON path (e.g., "Nodes.personal.ContentIndex.Path") + /// Human-readable error message + public ConfigException(string configPath, string message) + : base($"Configuration error at '{configPath}': {message}") + { + this.ConfigPath = configPath; + } + + /// + /// Creates a new configuration exception with inner exception + /// + /// JSON path + /// Human-readable error message + /// Underlying exception + public ConfigException(string configPath, string message, Exception innerException) + : base($"Configuration error at '{configPath}': {message}", innerException) + { + this.ConfigPath = configPath; + } + + public ConfigException() : base() + { + this.ConfigPath = string.Empty; + } + + public ConfigException(string? message) : base(message) + { + this.ConfigPath = string.Empty; + } + + public ConfigException(string? message, Exception? innerException) : base(message, innerException) + { + this.ConfigPath = string.Empty; + } +} diff --git a/src/Core/Config/Validation/IValidatable.cs b/src/Core/Config/Validation/IValidatable.cs new file mode 100644 index 000000000..55ea06d87 --- /dev/null +++ b/src/Core/Config/Validation/IValidatable.cs @@ -0,0 +1,14 @@ +namespace KernelMemory.Core.Config.Validation; + +/// +/// Interface for configuration objects that can validate themselves +/// +public interface IValidatable +{ + /// + /// Validates the configuration object + /// + /// JSON path for error reporting (e.g., "Nodes.personal.ContentIndex") + /// Thrown when validation fails + void Validate(string path); +} diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 237d66167..3a5c20e56 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -1,9 +1,9 @@  + KernelMemory.Core + KernelMemory.Core net10.0 - enable - enable diff --git a/src/Directory.Build.props b/src/Directory.Build.props index f6fe17bb0..62761c246 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -5,7 +5,7 @@ 0.100.0-alpha - 14 + latest enable @@ -26,10 +26,6 @@ portable - - $([System.IO.Path]::GetDirectoryName($([MSBuild]::GetPathOfFileAbove('LICENSE', '$(MSBuildThisFileDirectory)'))))/ - - @@ -138,6 +134,10 @@ true + + $([System.IO.Path]::GetDirectoryName($([MSBuild]::GetPathOfFileAbove('LICENSE', '$(MSBuildThisFileDirectory)'))))/ + + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index fe5b712be..fc4481b00 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -14,7 +14,6 @@ - diff --git a/src/Main/Main.csproj b/src/Main/Main.csproj index ba1dc76ab..c577398a8 100644 --- a/src/Main/Main.csproj +++ b/src/Main/Main.csproj @@ -1,11 +1,10 @@  - Exe + KernelMemory.Main + KernelMemory.Main net10.0 - latest - enable - enable + Exe true true diff --git a/src/Main/Program.cs b/src/Main/Program.cs index c860d1f6d..b779b96bb 100644 --- a/src/Main/Program.cs +++ b/src/Main/Program.cs @@ -1,4 +1,4 @@ -namespace Main; +namespace KernelMemory.Main; sealed class Program { diff --git a/tests/Core.Tests/Config/AppConfigTests.cs b/tests/Core.Tests/Config/AppConfigTests.cs new file mode 100644 index 000000000..d455109de --- /dev/null +++ b/tests/Core.Tests/Config/AppConfigTests.cs @@ -0,0 +1,208 @@ +using KernelMemory.Core.Config; +using KernelMemory.Core.Config.Cache; +using KernelMemory.Core.Config.ContentIndex; +using KernelMemory.Core.Config.Enums; +using KernelMemory.Core.Config.SearchIndex; +using KernelMemory.Core.Config.Validation; + +namespace Core.Tests.Config; + +/// +/// Tests for AppConfig validation and default configuration +/// +public class AppConfigTests +{ + [Fact] + public void CreateDefault_ShouldCreateValidConfiguration() + { + // Act + var config = AppConfig.CreateDefault(); + + // Assert + Assert.NotNull(config); + Assert.Single(config.Nodes); + Assert.True(config.Nodes.ContainsKey("personal")); + Assert.NotNull(config.EmbeddingsCache); + Assert.Null(config.LLMCache); + + // Verify personal node structure + var personalNode = config.Nodes["personal"]; + Assert.Equal("personal", personalNode.Id); + Assert.Equal(NodeAccessLevels.Full, personalNode.Access); + Assert.NotNull(personalNode.ContentIndex); + Assert.IsType(personalNode.ContentIndex); + Assert.Null(personalNode.FileStorage); + Assert.Null(personalNode.RepoStorage); + Assert.Equal(2, personalNode.SearchIndexes.Count); + + // Verify search indexes + Assert.IsType(personalNode.SearchIndexes[0]); + Assert.IsType(personalNode.SearchIndexes[1]); + + var ftsIndex = (FtsSearchIndexConfig)personalNode.SearchIndexes[0]; + Assert.Equal(SearchIndexTypes.SqliteFTS, ftsIndex.Type); + Assert.True(ftsIndex.EnableStemming); + Assert.NotNull(ftsIndex.Path); + Assert.Contains("fts.db", ftsIndex.Path); + + var vectorIndex = (VectorSearchIndexConfig)personalNode.SearchIndexes[1]; + Assert.Equal(SearchIndexTypes.SqliteVector, vectorIndex.Type); + Assert.Equal(768, vectorIndex.Dimensions); + Assert.Equal(VectorMetrics.Cosine, vectorIndex.Metric); + Assert.NotNull(vectorIndex.Path); + Assert.Contains("vectors.db", vectorIndex.Path); + + // Verify embeddings cache + Assert.Equal(CacheTypes.Sqlite, config.EmbeddingsCache.Type); + Assert.True(config.EmbeddingsCache.AllowRead); + Assert.True(config.EmbeddingsCache.AllowWrite); + Assert.NotNull(config.EmbeddingsCache.Path); + Assert.Contains("embeddings-cache.db", config.EmbeddingsCache.Path); + } + + [Fact] + public void Validate_WithValidConfig_ShouldNotThrow() + { + // Arrange + var config = AppConfig.CreateDefault(); + + // Act & Assert - should not throw + config.Validate(); + } + + [Fact] + public void Validate_WithNoNodes_ShouldThrowConfigException() + { + // Arrange + var config = new AppConfig + { + Nodes = new Dictionary() + }; + + // Act & Assert + var exception = Assert.Throws(() => config.Validate()); + Assert.Equal("Nodes", exception.ConfigPath); + Assert.Contains("At least one node must be configured", exception.Message); + } + + [Fact] + public void Validate_WithEmptyNodeId_ShouldThrowConfigException() + { + // Arrange + var config = new AppConfig + { + Nodes = new Dictionary + { + [""] = new NodeConfig + { + Id = "test", + ContentIndex = new SqliteContentIndexConfig { Path = "test.db" } + } + } + }; + + // Act & Assert + var exception = Assert.Throws(() => config.Validate()); + Assert.Equal("Nodes", exception.ConfigPath); + Assert.Contains("Node ID cannot be empty", exception.Message); + } + + [Fact] + public void Validate_WithInvalidNodeConfig_ShouldThrowConfigException() + { + // Arrange + var config = new AppConfig + { + Nodes = new Dictionary + { + ["test"] = new NodeConfig + { + Id = "", // Invalid: empty ID + ContentIndex = new SqliteContentIndexConfig { Path = "test.db" } + } + } + }; + + // Act & Assert + var exception = Assert.Throws(() => config.Validate()); + Assert.Equal("Nodes.test", exception.ConfigPath); + Assert.Contains("Node ID is required", exception.Message); + } + + [Fact] + public void Validate_WithInvalidEmbeddingsCache_ShouldThrowConfigException() + { + // Arrange + var config = AppConfig.CreateDefault(); + config.EmbeddingsCache = new CacheConfig + { + Type = CacheTypes.Sqlite, + Path = null // Invalid: sqlite needs path + }; + + // Act & Assert + var exception = Assert.Throws(() => config.Validate()); + Assert.Equal("EmbeddingsCache.Path", exception.ConfigPath); + Assert.Contains("SQLite cache requires Path", exception.Message); + } + + [Fact] + public void Validate_WithInvalidLLMCache_ShouldThrowConfigException() + { + // Arrange + var config = AppConfig.CreateDefault(); + config.LLMCache = new CacheConfig + { + Type = CacheTypes.Postgres, + ConnectionString = null // Invalid: postgres needs connection string + }; + + // Act & Assert + var exception = Assert.Throws(() => config.Validate()); + Assert.Equal("LLMCache.ConnectionString", exception.ConfigPath); + Assert.Contains("PostgreSQL cache requires ConnectionString", exception.Message); + } + + [Fact] + public void Validate_WithMultipleNodes_ShouldValidateAll() + { + // Arrange + var config = AppConfig.CreateDefault(); + config.Nodes["work"] = new NodeConfig + { + Id = "work", + Access = NodeAccessLevels.ReadOnly, + ContentIndex = new SqliteContentIndexConfig { Path = "work.db" } + }; + + // Act & Assert - should not throw + config.Validate(); + + // Verify both nodes + Assert.Equal(2, config.Nodes.Count); + Assert.True(config.Nodes.ContainsKey("personal")); + Assert.True(config.Nodes.ContainsKey("work")); + } + + [Fact] + public void Validate_PropagatesPathInErrors() + { + // Arrange + var config = new AppConfig + { + Nodes = new Dictionary + { + ["mynode"] = new NodeConfig + { + Id = "mynode", + ContentIndex = new SqliteContentIndexConfig { Path = "" } // Invalid: empty path + } + } + }; + + // Act & Assert + var exception = Assert.Throws(() => config.Validate()); + Assert.Equal("Nodes.mynode.ContentIndex.Path", exception.ConfigPath); + Assert.Contains("SQLite path is required", exception.Message); + } +} diff --git a/tests/Core.Tests/Config/ConfigParserTests.cs b/tests/Core.Tests/Config/ConfigParserTests.cs new file mode 100644 index 000000000..d6e6e53bd --- /dev/null +++ b/tests/Core.Tests/Config/ConfigParserTests.cs @@ -0,0 +1,365 @@ +using System.Text.Json; +using KernelMemory.Core.Config; +using KernelMemory.Core.Config.Validation; + +namespace Core.Tests.Config; + +/// +/// Tests for ConfigParser - loading and parsing configuration files +/// +public class ConfigParserTests +{ + [Fact] + public void LoadFromFile_WhenFileMissing_ShouldReturnDefaultConfig() + { + // Arrange + var nonExistentPath = Path.Combine(Path.GetTempPath(), $"nonexistent-{Guid.NewGuid()}.json"); + + // Act + var config = ConfigParser.LoadFromFile(nonExistentPath); + + // Assert + Assert.NotNull(config); + Assert.Single(config.Nodes); + Assert.True(config.Nodes.ContainsKey("personal")); + Assert.NotNull(config.EmbeddingsCache); + } + + [Fact] + public void LoadFromFile_WithValidJson_ShouldReturnParsedConfig() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""mynode"": { + ""id"": ""mynode"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + } + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act + var config = ConfigParser.LoadFromFile(tempFile); + + // Assert + Assert.NotNull(config); + Assert.Single(config.Nodes); + Assert.True(config.Nodes.ContainsKey("mynode")); + Assert.Equal("mynode", config.Nodes["mynode"].Id); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void LoadFromFile_WithInvalidJson_ShouldThrowConfigException() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var invalidJson = "{ invalid json here }"; + + try + { + File.WriteAllText(tempFile, invalidJson); + + // Act & Assert + var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile)); + Assert.Contains("Failed to parse configuration", exception.Message); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void LoadFromFile_WithValidationErrors_ShouldThrowConfigException() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": {} + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act & Assert + var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile)); + Assert.Equal("Nodes", exception.ConfigPath); + Assert.Contains("At least one node must be configured", exception.Message); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void LoadFromFile_WithTildeInPath_ShouldExpandToHomeDirectory() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""~/.km/test.db"" + } + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act + var config = ConfigParser.LoadFromFile(tempFile); + + // Assert + var contentIndex = (KernelMemory.Core.Config.ContentIndex.SqliteContentIndexConfig)config.Nodes["test"].ContentIndex; + Assert.NotNull(contentIndex.Path); + Assert.DoesNotContain("~", contentIndex.Path); + Assert.StartsWith(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), contentIndex.Path); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void LoadFromFile_WithCommentsInJson_ShouldParseSuccessfully() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var jsonWithComments = @"{ + // This is a comment + ""nodes"": { + ""test"": { + ""id"": ""test"", + /* Multi-line + comment */ + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + } + } + } + }"; + + try + { + File.WriteAllText(tempFile, jsonWithComments); + + // Act + var config = ConfigParser.LoadFromFile(tempFile); + + // Assert + Assert.NotNull(config); + Assert.Single(config.Nodes); + Assert.True(config.Nodes.ContainsKey("test")); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void LoadFromFile_WithCaseInsensitiveProperties_ShouldParseSuccessfully() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""Nodes"": { + ""test"": { + ""Id"": ""test"", + ""Access"": ""Full"", + ""ContentIndex"": { + ""$type"": ""sqlite"", + ""Path"": ""test.db"" + } + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act + var config = ConfigParser.LoadFromFile(tempFile); + + // Assert + Assert.NotNull(config); + Assert.Single(config.Nodes); + Assert.Equal("test", config.Nodes["test"].Id); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void ParseFromString_WithValidJson_ShouldReturnParsedConfig() + { + // Arrange + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + } + } + } + }"; + + // Act + var config = ConfigParser.ParseFromString(json); + + // Assert + Assert.NotNull(config); + Assert.Single(config.Nodes); + Assert.Equal("test", config.Nodes["test"].Id); + } + + [Fact] + public void ParseFromString_WithInvalidJson_ShouldThrowConfigException() + { + // Arrange + var invalidJson = "{ invalid json }"; + + // Act & Assert + var exception = Assert.Throws(() => ConfigParser.ParseFromString(invalidJson)); + Assert.Contains("Failed to parse configuration", exception.Message); + } + + [Fact] + public void LoadFromFile_WithCacheTildeExpansion_ShouldExpandPaths() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + } + } + }, + ""embeddingsCache"": { + ""type"": ""Sqlite"", + ""path"": ""~/embeddings-cache.db"" + }, + ""llmCache"": { + ""type"": ""Sqlite"", + ""path"": ""~/llm-cache.db"" + } + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act + var config = ConfigParser.LoadFromFile(tempFile); + + // Assert + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + Assert.NotNull(config.EmbeddingsCache); + Assert.NotNull(config.LLMCache); + Assert.StartsWith(homeDir, config.EmbeddingsCache.Path!); + Assert.StartsWith(homeDir, config.LLMCache.Path!); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void LoadFromFile_WithCacheBothPathAndConnectionString_ShouldThrowConfigException() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + } + } + }, + ""embeddingsCache"": { + ""type"": ""Sqlite"", + ""path"": ""cache.db"", + ""connectionString"": ""Host=localhost"" + } + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act & Assert + var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile)); + Assert.Contains("specify either Path", exception.Message); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } +} diff --git a/tests/Core.Tests/Config/ContentIndexConfigTests.cs b/tests/Core.Tests/Config/ContentIndexConfigTests.cs new file mode 100644 index 000000000..108e351b1 --- /dev/null +++ b/tests/Core.Tests/Config/ContentIndexConfigTests.cs @@ -0,0 +1,85 @@ +using KernelMemory.Core.Config; +using KernelMemory.Core.Config.Validation; + +namespace Core.Tests.Config; + +/// +/// Tests for Content Index configuration validation +/// +public class ContentIndexConfigTests +{ + [Fact] + public void LoadFromFile_WithPostgresContentIndex_ShouldValidate() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""postgres"", + ""connectionString"": ""Host=localhost;Database=testdb;Username=test;Password=test"" + } + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act + var config = ConfigParser.LoadFromFile(tempFile); + + // Assert + Assert.NotNull(config); + var node = config.Nodes["test"]; + var postgresIndex = Assert.IsType(node.ContentIndex); + Assert.Equal("Host=localhost;Database=testdb;Username=test;Password=test", postgresIndex.ConnectionString); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void LoadFromFile_WithPostgresContentIndexMissingConnectionString_ShouldThrowConfigException() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""postgres"", + ""connectionString"": """" + } + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act & Assert + var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile)); + Assert.Contains("PostgreSQL connection string is required", exception.Message); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } +} diff --git a/tests/Core.Tests/Config/EmbeddingsConfigTests.cs b/tests/Core.Tests/Config/EmbeddingsConfigTests.cs new file mode 100644 index 000000000..f97564bfc --- /dev/null +++ b/tests/Core.Tests/Config/EmbeddingsConfigTests.cs @@ -0,0 +1,522 @@ +using KernelMemory.Core.Config; +using KernelMemory.Core.Config.Validation; + +namespace Core.Tests.Config; + +/// +/// Tests for Embeddings configuration validation +/// +public class EmbeddingsConfigTests +{ + [Fact] + public void LoadFromFile_WithOllamaEmbeddings_ShouldValidate() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + }, + ""searchIndexes"": [ + { + ""$type"": ""vector"", + ""type"": ""SqliteVector"", + ""path"": ""vector.db"", + ""dimensions"": 384, + ""embeddings"": { + ""$type"": ""ollama"", + ""model"": ""all-minilm"", + ""baseUrl"": ""http://localhost:11434"" + } + } + ] + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act + var config = ConfigParser.LoadFromFile(tempFile); + + // Assert + Assert.NotNull(config); + var searchIndex = config.Nodes["test"].SearchIndexes[0]; + Assert.NotNull(searchIndex.Embeddings); + var ollamaConfig = Assert.IsType(searchIndex.Embeddings); + Assert.Equal("all-minilm", ollamaConfig.Model); + Assert.Equal("http://localhost:11434", ollamaConfig.BaseUrl); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void LoadFromFile_WithOpenAIEmbeddings_ShouldValidate() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + }, + ""searchIndexes"": [ + { + ""$type"": ""vector"", + ""type"": ""SqliteVector"", + ""path"": ""vector.db"", + ""dimensions"": 1536, + ""embeddings"": { + ""$type"": ""openai"", + ""model"": ""text-embedding-ada-002"", + ""apiKey"": ""test-key"", + ""baseUrl"": ""https://api.openai.com/v1"" + } + } + ] + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act + var config = ConfigParser.LoadFromFile(tempFile); + + // Assert + Assert.NotNull(config); + var searchIndex = config.Nodes["test"].SearchIndexes[0]; + Assert.NotNull(searchIndex.Embeddings); + var openaiConfig = Assert.IsType(searchIndex.Embeddings); + Assert.Equal("text-embedding-ada-002", openaiConfig.Model); + Assert.Equal("test-key", openaiConfig.ApiKey); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void LoadFromFile_WithOllamaEmbeddingsMissingModel_ShouldThrowConfigException() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + }, + ""searchIndexes"": [ + { + ""$type"": ""vector"", + ""type"": ""SqliteVector"", + ""path"": ""vector.db"", + ""dimensions"": 384, + ""embeddings"": { + ""$type"": ""ollama"", + ""model"": """", + ""baseUrl"": ""http://localhost:11434"" + } + } + ] + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act & Assert + var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile)); + Assert.Contains("Ollama model name is required", exception.Message); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void LoadFromFile_WithOllamaEmbeddingsMissingBaseUrl_ShouldThrowConfigException() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + }, + ""searchIndexes"": [ + { + ""$type"": ""vector"", + ""type"": ""SqliteVector"", + ""path"": ""vector.db"", + ""dimensions"": 384, + ""embeddings"": { + ""$type"": ""ollama"", + ""model"": ""all-minilm"", + ""baseUrl"": """" + } + } + ] + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act & Assert + var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile)); + Assert.Contains("Ollama base URL is required", exception.Message); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void LoadFromFile_WithOpenAIEmbeddingsMissingApiKey_ShouldThrowConfigException() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + }, + ""searchIndexes"": [ + { + ""$type"": ""vector"", + ""type"": ""SqliteVector"", + ""path"": ""vector.db"", + ""dimensions"": 1536, + ""embeddings"": { + ""$type"": ""openai"", + ""model"": ""text-embedding-ada-002"", + ""apiKey"": """" + } + } + ] + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act & Assert + var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile)); + Assert.Contains("OpenAI API key is required", exception.Message); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void LoadFromFile_WithAzureOpenAIEmbeddingsMissingModel_ShouldThrowConfigException() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + }, + ""searchIndexes"": [ + { + ""$type"": ""vector"", + ""type"": ""SqliteVector"", + ""path"": ""vector.db"", + ""dimensions"": 1536, + ""embeddings"": { + ""$type"": ""azureOpenAI"", + ""model"": """", + ""endpoint"": ""https://test.openai.azure.com"", + ""deployment"": ""test-deployment"", + ""apiKey"": ""test-key"" + } + } + ] + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act & Assert + var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile)); + Assert.Contains("Model name is required", exception.Message); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void LoadFromFile_WithAzureOpenAIEmbeddingsMissingEndpoint_ShouldThrowConfigException() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + }, + ""searchIndexes"": [ + { + ""$type"": ""vector"", + ""type"": ""SqliteVector"", + ""path"": ""vector.db"", + ""dimensions"": 1536, + ""embeddings"": { + ""$type"": ""azureOpenAI"", + ""model"": ""text-embedding-ada-002"", + ""endpoint"": """", + ""deployment"": ""test-deployment"", + ""apiKey"": ""test-key"" + } + } + ] + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act & Assert + var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile)); + Assert.Contains("Azure OpenAI endpoint is required", exception.Message); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void LoadFromFile_WithAzureOpenAIEmbeddingsMissingDeployment_ShouldThrowConfigException() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + }, + ""searchIndexes"": [ + { + ""$type"": ""vector"", + ""type"": ""SqliteVector"", + ""path"": ""vector.db"", + ""dimensions"": 1536, + ""embeddings"": { + ""$type"": ""azureOpenAI"", + ""model"": ""text-embedding-ada-002"", + ""endpoint"": ""https://test.openai.azure.com"", + ""deployment"": """", + ""apiKey"": ""test-key"" + } + } + ] + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act & Assert + var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile)); + Assert.Contains("Deployment name is required", exception.Message); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void LoadFromFile_WithAzureOpenAIEmbeddingsNoAuth_ShouldThrowConfigException() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + }, + ""searchIndexes"": [ + { + ""$type"": ""vector"", + ""type"": ""SqliteVector"", + ""path"": ""vector.db"", + ""dimensions"": 1536, + ""embeddings"": { + ""$type"": ""azureOpenAI"", + ""model"": ""text-embedding-ada-002"", + ""endpoint"": ""https://test.openai.azure.com"", + ""deployment"": ""test-deployment"" + } + } + ] + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act & Assert + var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile)); + Assert.Contains("requires either ApiKey or UseManagedIdentity", exception.Message); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void LoadFromFile_WithAzureOpenAIEmbeddings_ShouldValidate() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + }, + ""searchIndexes"": [ + { + ""$type"": ""vector"", + ""type"": ""SqliteVector"", + ""path"": ""vector.db"", + ""dimensions"": 1536, + ""embeddings"": { + ""$type"": ""azureOpenAI"", + ""model"": ""text-embedding-ada-002"", + ""endpoint"": ""https://test.openai.azure.com"", + ""deployment"": ""text-embedding-ada-002"", + ""apiKey"": ""test-key"" + } + } + ] + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act + var config = ConfigParser.LoadFromFile(tempFile); + + // Assert + Assert.NotNull(config); + var searchIndex = config.Nodes["test"].SearchIndexes[0]; + Assert.NotNull(searchIndex.Embeddings); + var azureConfig = Assert.IsType(searchIndex.Embeddings); + Assert.Equal("text-embedding-ada-002", azureConfig.Model); + Assert.Equal("https://test.openai.azure.com", azureConfig.Endpoint); + Assert.Equal("text-embedding-ada-002", azureConfig.Deployment); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } +} diff --git a/tests/Core.Tests/Config/SearchIndexConfigTests.cs b/tests/Core.Tests/Config/SearchIndexConfigTests.cs new file mode 100644 index 000000000..5d849524e --- /dev/null +++ b/tests/Core.Tests/Config/SearchIndexConfigTests.cs @@ -0,0 +1,183 @@ +using KernelMemory.Core.Config; +using KernelMemory.Core.Config.Validation; + +namespace Core.Tests.Config; + +/// +/// Tests for Search Index configuration validation +/// +public class SearchIndexConfigTests +{ + [Fact] + public void LoadFromFile_WithGraphSearchIndex_ShouldExpandTildePath() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + }, + ""searchIndexes"": [ + { + ""$type"": ""graph"", + ""path"": ""~/graph-index.db"" + } + ] + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act + var config = ConfigParser.LoadFromFile(tempFile); + + // Assert + Assert.NotNull(config); + var node = config.Nodes["test"]; + Assert.Single(node.SearchIndexes); + var graphIndex = Assert.IsType(node.SearchIndexes[0]); + + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + Assert.StartsWith(homeDir, graphIndex.Path); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void LoadFromFile_WithFtsSearchIndexBothPathAndConnection_ShouldThrowConfigException() + { + // Test: FTS with both Path and ConnectionString + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + }, + ""searchIndexes"": [ + { + ""$type"": ""fts"", + ""type"": ""SqliteFTS"", + ""path"": ""fts.db"", + ""connectionString"": ""Host=localhost"" + } + ] + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile)); + Assert.Contains("specify either Path", exception.Message); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void LoadFromFile_WithVectorSearchIndexBothPathAndConnection_ShouldThrowConfigException() + { + // Test: Vector with both Path and ConnectionString + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + }, + ""searchIndexes"": [ + { + ""$type"": ""vector"", + ""type"": ""SqliteVector"", + ""path"": ""vector.db"", + ""connectionString"": ""Host=localhost"", + ""dimensions"": 384 + } + ] + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile)); + Assert.Contains("specify either Path", exception.Message); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void LoadFromFile_WithVectorSearchIndexInvalidDimensions_ShouldThrowConfigException() + { + // Test: Vector with invalid Dimensions + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + }, + ""searchIndexes"": [ + { + ""$type"": ""vector"", + ""type"": ""SqliteVector"", + ""path"": ""vector.db"", + ""dimensions"": 0 + } + ] + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile)); + Assert.Contains("must be positive", exception.Message); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } +} diff --git a/tests/Core.Tests/Config/StorageConfigTests.cs b/tests/Core.Tests/Config/StorageConfigTests.cs new file mode 100644 index 000000000..616b9ac38 --- /dev/null +++ b/tests/Core.Tests/Config/StorageConfigTests.cs @@ -0,0 +1,267 @@ +using KernelMemory.Core.Config; +using KernelMemory.Core.Config.Validation; + +namespace Core.Tests.Config; + +/// +/// Tests for Storage configuration validation and parsing +/// +public class StorageConfigTests +{ + [Fact] + public void LoadFromFile_WithDiskStorage_ShouldExpandTildePath() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + }, + ""fileStorage"": { + ""$type"": ""disk"", + ""path"": ""~/test-files"" + }, + ""repoStorage"": { + ""$type"": ""disk"", + ""path"": ""~/test-repos"" + } + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act + var config = ConfigParser.LoadFromFile(tempFile); + + // Assert + Assert.NotNull(config); + var node = config.Nodes["test"]; + Assert.NotNull(node.FileStorage); + Assert.NotNull(node.RepoStorage); + + var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var fileStorage = Assert.IsType(node.FileStorage); + var repoStorage = Assert.IsType(node.RepoStorage); + Assert.StartsWith(homeDir, fileStorage.Path); + Assert.StartsWith(homeDir, repoStorage.Path); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void LoadFromFile_WithDiskStorageMissingPath_ShouldThrowConfigException() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + }, + ""fileStorage"": { + ""$type"": ""disk"", + ""path"": """" + } + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act & Assert + var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile)); + Assert.Contains("Disk storage path is required", exception.Message); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void LoadFromFile_WithAzureBlobStorageConnectionString_ShouldValidate() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + }, + ""fileStorage"": { + ""$type"": ""azureBlobs"", + ""connectionString"": ""DefaultEndpointsProtocol=https;AccountName=test;AccountKey=key;EndpointSuffix=core.windows.net"" + } + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act + var config = ConfigParser.LoadFromFile(tempFile); + + // Assert + Assert.NotNull(config); + Assert.NotNull(config.Nodes["test"].FileStorage); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void LoadFromFile_WithAzureBlobStorageApiKey_ShouldValidate() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + }, + ""fileStorage"": { + ""$type"": ""azureBlobs"", + ""account"": ""testaccount"", + ""apiKey"": ""test-api-key"" + } + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act + var config = ConfigParser.LoadFromFile(tempFile); + + // Assert + Assert.NotNull(config); + Assert.NotNull(config.Nodes["test"].FileStorage); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void LoadFromFile_WithAzureBlobStorageNoAuth_ShouldThrowConfigException() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + }, + ""fileStorage"": { + ""$type"": ""azureBlobs"", + ""account"": ""testaccount"" + } + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act & Assert + var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile)); + Assert.Contains("Azure Blob storage requires one of", exception.Message); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void LoadFromFile_WithAzureBlobStorageMultipleAuth_ShouldThrowConfigException() + { + // Arrange + var tempFile = Path.Combine(Path.GetTempPath(), $"config-{Guid.NewGuid()}.json"); + var json = @"{ + ""nodes"": { + ""test"": { + ""id"": ""test"", + ""access"": ""Full"", + ""contentIndex"": { + ""$type"": ""sqlite"", + ""path"": ""test.db"" + }, + ""fileStorage"": { + ""$type"": ""azureBlobs"", + ""connectionString"": ""DefaultEndpointsProtocol=https;AccountName=test;AccountKey=key"", + ""apiKey"": ""test-api-key"" + } + } + } + }"; + + try + { + File.WriteAllText(tempFile, json); + + // Act & Assert + var exception = Assert.Throws(() => ConfigParser.LoadFromFile(tempFile)); + Assert.Contains("specify only one authentication method", exception.Message); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } +} diff --git a/tests/Core.Tests/Core.Tests.csproj b/tests/Core.Tests/Core.Tests.csproj new file mode 100644 index 000000000..cd434d381 --- /dev/null +++ b/tests/Core.Tests/Core.Tests.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + + false + + + $(NoWarn);CA1707;CA1307 + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 000000000..6afe069d9 --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,90 @@ + + + + false + + + latest + + + enable + + + enable + + + LatestMajor + + + + true + full + + + + portable + + + + + + <_Parameter1>false + + + + + + + + + + true + true + All + latest + + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/tests/Directory.Packages.props b/tests/Directory.Packages.props new file mode 100644 index 000000000..d47ecb08e --- /dev/null +++ b/tests/Directory.Packages.props @@ -0,0 +1,24 @@ + + + true + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file