Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ namespace Celbridge.Packages;
public interface IPackageLocalizationService
{
/// <summary>
/// Loads localization strings from a package's localization folder.
/// Uses convention: {packageFolder}/localization/{locale}.json
/// If locale is null, uses the current UI culture.
/// Falls back to "en.json" if the requested locale is not found, then to an empty dictionary.
/// Loads localization strings for the supplied package. Uses the convention
/// {package.PackageFolder}/localization/{locale}.json, with package.Origin
/// selecting whether the underlying read crosses the IFileStorage chokepoint.
/// If locale is null, uses the current UI culture. Falls back to "en.json"
/// if the requested locale is not found, then to an empty dictionary.
/// </summary>
Dictionary<string, string> LoadStrings(string packageFolder, string? locale = null);
Dictionary<string, string> LoadStrings(PackageInfo package, string? locale = null);
}
29 changes: 29 additions & 0 deletions Source/Core/Celbridge.Foundation/Packages/PackageInfo.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
namespace Celbridge.Packages;

/// <summary>
/// Discovery origin of a package. Determines whether file reads cross the
/// IFileStorage chokepoint (Project) or stay on direct File.* IO (Bundled,
/// until the bundled-from-assembly migration lands).
/// </summary>
public enum PackageOrigin
{
/// <summary>
/// First-party package shipped inside a Celbridge module DLL. Read sites
/// stay on direct File.* IO against the install folder.
/// </summary>
Bundled,

/// <summary>
/// User package discovered under the project's packages/ folder. Read
/// sites resolve a ResourceKey via IResourceRegistry and read through
/// IFileStorage so the chokepoint contract holds for every project-tree
/// byte.
/// </summary>
Project
}

/// <summary>
/// Package identity, permissions, and hosting information.
/// Shared across all contributions from the same package.
Expand Down Expand Up @@ -42,6 +64,13 @@ public partial record PackageInfo
/// </summary>
public bool DevToolsBlocked { get; init; }

/// <summary>
/// Whether the package was discovered as a bundled (in-module) or project
/// (project-tree) package. Drives the read path selection at every site
/// that loads bytes for the package.
/// </summary>
public PackageOrigin Origin { get; init; }

/// <summary>
/// The folder containing the package (set during loading, not from TOML).
/// </summary>
Expand Down
16 changes: 14 additions & 2 deletions Source/Tests/Packages/FileTypeProviderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,6 @@ public void Setup()

var logger = Substitute.For<ILogger<PackageRegistry>>();
var messengerService = Substitute.For<IMessengerService>();
var localizationLogger = Substitute.For<ILogger<PackageLocalizationService>>();
var localizationService = new PackageLocalizationService(localizationLogger);

var resourceRegistry = Substitute.For<IResourceRegistry>();
resourceRegistry.ProjectFolderPath.Returns(_tempProjectFolder);
Expand All @@ -44,6 +42,17 @@ public void Setup()
var key = callInfo.Arg<ResourceKey>();
return Result<string>.Ok(Path.Combine(_tempProjectFolder, key.Path.Replace('/', Path.DirectorySeparatorChar)));
});
resourceRegistry.GetResourceKey(Arg.Any<string>()).Returns(callInfo =>
{
var path = callInfo.Arg<string>();
if (!path.StartsWith(_tempProjectFolder, StringComparison.OrdinalIgnoreCase))
{
return Result<ResourceKey>.Fail($"Path '{path}' is not under the project root");
}
var relative = Path.GetRelativePath(_tempProjectFolder, path)
.Replace(Path.DirectorySeparatorChar, '/');
return Result<ResourceKey>.Ok(new ResourceKey(relative));
});

var resourceService = Substitute.For<IResourceService>();
resourceService.Registry.Returns(resourceRegistry);
Expand All @@ -60,6 +69,9 @@ public void Setup()
workspaceWrapper);
workspaceService.FileStorage.Returns(fileStorage);

var localizationLogger = Substitute.For<ILogger<PackageLocalizationService>>();
var localizationService = new PackageLocalizationService(localizationLogger, workspaceWrapper);

var registry = new PackageRegistry(logger, _moduleService, _featureFlags, localizationService, workspaceWrapper);
_service = new PackageService(messengerService, registry);
}
Expand Down
31 changes: 23 additions & 8 deletions Source/Tests/Packages/LocalizationHelperTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Celbridge.Packages;
using Celbridge.Workspace;

namespace Celbridge.Tests.Packages;

Expand All @@ -7,6 +8,7 @@ public class PackageLocalizationServiceTests
{
private string _tempFolder = null!;
private IPackageLocalizationService _service = null!;
private PackageInfo _bundledPackage = null!;

[SetUp]
public void Setup()
Expand All @@ -15,7 +17,20 @@ public void Setup()
Directory.CreateDirectory(_tempFolder);

var logger = Substitute.For<ILogger<PackageLocalizationService>>();
_service = new PackageLocalizationService(logger);

// The localization service only consults the workspace wrapper for
// Project-origin packages; tests use Bundled-origin packages so the
// wrapper is never dereferenced.
var workspaceWrapper = Substitute.For<IWorkspaceWrapper>();
_service = new PackageLocalizationService(logger, workspaceWrapper);

_bundledPackage = new PackageInfo
{
Id = "test.package",
Name = "Test Package",
PackageFolder = _tempFolder,
Origin = PackageOrigin.Bundled
};
}

[TearDown]
Expand All @@ -39,7 +54,7 @@ public void LoadStrings_ExactLocale_ReturnsStrings()
}
""");

var result = _service.LoadStrings(_tempFolder, "fr");
var result = _service.LoadStrings(_bundledPackage, "fr");

result.Should().HaveCount(2);
result["Greeting"].Should().Be("Bonjour");
Expand All @@ -58,7 +73,7 @@ public void LoadStrings_MissingLocale_FallsBackToEnglish()
}
""");

var result = _service.LoadStrings(_tempFolder, "ja");
var result = _service.LoadStrings(_bundledPackage, "ja");

result.Should().HaveCount(2);
result["Hello"].Should().Be("Hello");
Expand All @@ -71,15 +86,15 @@ public void LoadStrings_NoLocaleFiles_ReturnsEmptyDictionary()
var localizationFolder = Path.Combine(_tempFolder, PackageLocalizationService.LocalizationFolder);
Directory.CreateDirectory(localizationFolder);

var result = _service.LoadStrings(_tempFolder, "de");
var result = _service.LoadStrings(_bundledPackage, "de");

result.Should().BeEmpty();
}

[Test]
public void LoadStrings_NoLocalizationFolder_ReturnsEmptyDictionary()
{
var result = _service.LoadStrings(_tempFolder, "en");
var result = _service.LoadStrings(_bundledPackage, "en");

result.Should().BeEmpty();
}
Expand All @@ -91,7 +106,7 @@ public void LoadStrings_InvalidJson_ReturnsEmptyDictionary()
Directory.CreateDirectory(localizationFolder);
File.WriteAllText(Path.Combine(localizationFolder, "en.json"), "{ not valid json }");

var result = _service.LoadStrings(_tempFolder, "en");
var result = _service.LoadStrings(_bundledPackage, "en");

result.Should().BeEmpty();
}
Expand All @@ -107,7 +122,7 @@ public void LoadStrings_EnglishRequested_DoesNotDoubleLoad()
}
""");

var result = _service.LoadStrings(_tempFolder, "en");
var result = _service.LoadStrings(_bundledPackage, "en");

result.Should().HaveCount(1);
result["Key"].Should().Be("Value");
Expand All @@ -126,7 +141,7 @@ public void LoadStrings_CommentsAndTrailingCommas_AreAllowed()
}
""");

var result = _service.LoadStrings(_tempFolder, "en");
var result = _service.LoadStrings(_bundledPackage, "en");

result.Should().HaveCount(2);
result["Key1"].Should().Be("Value1");
Expand Down
16 changes: 14 additions & 2 deletions Source/Tests/Packages/PackageRegistryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ public void Setup()

var logger = Substitute.For<ILogger<PackageRegistry>>();
_messengerService = Substitute.For<IMessengerService>();
var localizationLogger = Substitute.For<ILogger<PackageLocalizationService>>();
var localizationService = new PackageLocalizationService(localizationLogger);
_moduleService = Substitute.For<IModuleService>();
_moduleService.GetBundledPackages().Returns(new List<BundledPackageDescriptor>());
var featureFlags = Substitute.For<IFeatureFlags>();
Expand All @@ -40,6 +38,17 @@ public void Setup()
var key = callInfo.Arg<ResourceKey>();
return Result<string>.Ok(Path.Combine(_tempProjectFolder, key.Path.Replace('/', Path.DirectorySeparatorChar)));
});
_resourceRegistry.GetResourceKey(Arg.Any<string>()).Returns(callInfo =>
{
var path = callInfo.Arg<string>();
if (!path.StartsWith(_tempProjectFolder, StringComparison.OrdinalIgnoreCase))
{
return Result<ResourceKey>.Fail($"Path '{path}' is not under the project root");
}
var relative = Path.GetRelativePath(_tempProjectFolder, path)
.Replace(Path.DirectorySeparatorChar, '/');
return Result<ResourceKey>.Ok(new ResourceKey(relative));
});

var resourceService = Substitute.For<IResourceService>();
resourceService.Registry.Returns(_resourceRegistry);
Expand All @@ -56,6 +65,9 @@ public void Setup()
workspaceWrapper);
workspaceService.FileStorage.Returns(fileStorage);

var localizationLogger = Substitute.For<ILogger<PackageLocalizationService>>();
var localizationService = new PackageLocalizationService(localizationLogger, workspaceWrapper);

var registry = new PackageRegistry(logger, _moduleService, featureFlags, localizationService, workspaceWrapper);
_service = new PackageService(_messengerService, registry);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ private string ResolveDisplayName(IPackageLocalizationService localizationServic
// value when the key is not present (which also handles plain strings).
var displayKey = _contribution.DisplayName;

var localizationStrings = localizationService.LoadStrings(_contribution.Package.PackageFolder);
var localizationStrings = localizationService.LoadStrings(_contribution.Package);
if (localizationStrings.TryGetValue(displayKey, out var localizedName))
{
return localizedName;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
namespace Celbridge.Packages;

/// <summary>
/// Reader used for bundled packages. Reads come straight off disk because
/// bundled assets sit outside any IResourceRegistry root and cannot be
/// addressed by a ResourceKey. Replaced by an assembly-resource reader when
/// the bundled-from-assembly migration lands.
/// </summary>
public sealed class DirectPackageReader : IPackageReader
{
public bool Exists(string absolutePath)
{
return File.Exists(absolutePath);
}

public Result<string> ReadAllText(string absolutePath)
{
try
{
return File.ReadAllText(absolutePath);
}
catch (Exception ex)
{
return Result<string>.Fail($"Failed to read file: '{absolutePath}'")
.WithException(ex);
}
}

public Result<byte[]> ReadAllBytes(string absolutePath)
{
try
{
return File.ReadAllBytes(absolutePath);
}
catch (Exception ex)
{
return Result<byte[]>.Fail($"Failed to read file: '{absolutePath}'")
.WithException(ex);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using Celbridge.Resources;

namespace Celbridge.Packages;

/// <summary>
/// Reader used for project packages. Reverse-resolves the absolute path to a
/// ResourceKey via IResourceRegistry and routes every read through IFileStorage
/// so the chokepoint contract holds for project-tree bytes.
///
/// IPackageReader is sync to match its sync callers (PackageManifestLoader,
/// PackageLocalizationService) but IFileStorage is genuinely async. Each call
/// is dispatched through Task.Run before the blocking wait so the async work
/// starts on a thread-pool thread whose continuations do not try to resume on
/// the caller's SynchronizationContext. Without that, calling this reader from
/// the UI thread (workspace load, template fetch) deadlocks: the await
/// continuations inside FileStorage would post back to the UI thread the
/// outer GetResult is blocking.
/// </summary>
public sealed class FileStoragePackageReader : IPackageReader
{
private readonly IFileStorage _fileStorage;
private readonly IResourceRegistry _resourceRegistry;

public FileStoragePackageReader(
IFileStorage fileStorage,
IResourceRegistry resourceRegistry)
{
_fileStorage = fileStorage;
_resourceRegistry = resourceRegistry;
}

public bool Exists(string absolutePath)
{
var keyResult = _resourceRegistry.GetResourceKey(absolutePath);
if (keyResult.IsFailure)
{
return false;
}

var infoResult = Task.Run(() => _fileStorage.GetInfoAsync(keyResult.Value))
.GetAwaiter()
.GetResult();
if (infoResult.IsFailure)
{
return false;
}

return infoResult.Value.Kind != StorageItemKind.NotFound;
}

public Result<string> ReadAllText(string absolutePath)
{
var keyResult = _resourceRegistry.GetResourceKey(absolutePath);
if (keyResult.IsFailure)
{
return Result<string>.Fail($"Could not resolve resource key for path: '{absolutePath}'")
.WithErrors(keyResult);
}

return Task.Run(() => _fileStorage.ReadAllTextAsync(keyResult.Value))
.GetAwaiter()
.GetResult();
}

public Result<byte[]> ReadAllBytes(string absolutePath)
{
var keyResult = _resourceRegistry.GetResourceKey(absolutePath);
if (keyResult.IsFailure)
{
return Result<byte[]>.Fail($"Could not resolve resource key for path: '{absolutePath}'")
.WithErrors(keyResult);
}

return Task.Run(() => _fileStorage.ReadAllBytesAsync(keyResult.Value))
.GetAwaiter()
.GetResult();
}
}
26 changes: 26 additions & 0 deletions Source/Workspace/Celbridge.Packages/Services/IPackageReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace Celbridge.Packages;

/// <summary>
/// Synchronous file-read primitives used by the package layer to load
/// manifests, localization, templates, and extensions lists. The interface
/// keeps PackageManifestLoader and PackageLocalizationService unaware of
/// whether the bytes ultimately come from disk (bundled) or from IFileStorage
/// (project), so each loader can stay on a single sync code path.
/// </summary>
public interface IPackageReader
{
/// <summary>
/// True when a file exists at the given absolute path.
/// </summary>
bool Exists(string absolutePath);

/// <summary>
/// Reads the file at the given absolute path as UTF-8 text.
/// </summary>
Result<string> ReadAllText(string absolutePath);

/// <summary>
/// Reads the file at the given absolute path as raw bytes.
/// </summary>
Result<byte[]> ReadAllBytes(string absolutePath);
}
Loading
Loading