diff --git a/src/Alma.Abstraction/Alma.Abstraction.csproj b/src/Alma.Abstraction/Alma.Abstraction.csproj
new file mode 100644
index 0000000..431d608
--- /dev/null
+++ b/src/Alma.Abstraction/Alma.Abstraction.csproj
@@ -0,0 +1,10 @@
+
+
+
+ net6.0
+ enable
+ enable
+ Alma
+
+
+
diff --git a/src/Alma.Abstraction/Command/ICommand.cs b/src/Alma.Abstraction/Command/ICommand.cs
new file mode 100644
index 0000000..3d0d2e4
--- /dev/null
+++ b/src/Alma.Abstraction/Command/ICommand.cs
@@ -0,0 +1,7 @@
+namespace Alma.Command;
+
+public interface ICommand
+{
+ public string CommandString { get; }
+ public Task Run(List parameters);
+}
\ No newline at end of file
diff --git a/src/Alma.Abstraction/Configuration/Module/ModuleConfiguration.cs b/src/Alma.Abstraction/Configuration/Module/ModuleConfiguration.cs
new file mode 100644
index 0000000..b8e9fbc
--- /dev/null
+++ b/src/Alma.Abstraction/Configuration/Module/ModuleConfiguration.cs
@@ -0,0 +1,17 @@
+namespace Alma.Configuration.Module;
+
+public record ModuleConfiguration(string? Target, Dictionary? Links)
+{
+ public ModuleConfiguration Merge(ModuleConfiguration merge)
+ {
+ var mergedLinks = (Links ?? new Dictionary())
+ .Concat(merge.Links ?? new Dictionary());
+ return new ModuleConfiguration(
+ merge.Target ?? Target,
+ new Dictionary(mergedLinks)
+ );
+ }
+
+ public static ModuleConfiguration Empty() =>
+ new(null, new Dictionary());
+}
\ No newline at end of file
diff --git a/src/Alma.Abstraction/Configuration/Module/ModuleConfigurationRoot.cs b/src/Alma.Abstraction/Configuration/Module/ModuleConfigurationRoot.cs
new file mode 100644
index 0000000..6121119
--- /dev/null
+++ b/src/Alma.Abstraction/Configuration/Module/ModuleConfigurationRoot.cs
@@ -0,0 +1,5 @@
+namespace Alma.Configuration.Module;
+
+public class ModuleConfigurationRoot : Dictionary
+{
+}
\ No newline at end of file
diff --git a/src/Alma.Abstraction/Configuration/Repository/IRepositoryConfiguration.cs b/src/Alma.Abstraction/Configuration/Repository/IRepositoryConfiguration.cs
new file mode 100644
index 0000000..45c7a56
--- /dev/null
+++ b/src/Alma.Abstraction/Configuration/Repository/IRepositoryConfiguration.cs
@@ -0,0 +1,7 @@
+namespace Alma.Configuration.Repository;
+
+public interface IRepositoryConfiguration
+{
+ public Task LoadAsync();
+ RepositoryConfigurationRoot Configuration { get; }
+}
\ No newline at end of file
diff --git a/src/Alma.Abstraction/Configuration/Repository/RepositoryConfigurationEntry.cs b/src/Alma.Abstraction/Configuration/Repository/RepositoryConfigurationEntry.cs
new file mode 100644
index 0000000..93bf6b7
--- /dev/null
+++ b/src/Alma.Abstraction/Configuration/Repository/RepositoryConfigurationEntry.cs
@@ -0,0 +1,7 @@
+namespace Alma.Configuration.Repository;
+
+public record RepositoryConfigurationEntry(
+ string Name,
+ string? RepositoryPath,
+ string? LinkPath
+);
\ No newline at end of file
diff --git a/src/Alma.Abstraction/Configuration/Repository/RepositoryConfigurationRoot.cs b/src/Alma.Abstraction/Configuration/Repository/RepositoryConfigurationRoot.cs
new file mode 100644
index 0000000..5f4fdb8
--- /dev/null
+++ b/src/Alma.Abstraction/Configuration/Repository/RepositoryConfigurationRoot.cs
@@ -0,0 +1,3 @@
+namespace Alma.Configuration.Repository;
+
+public record RepositoryConfigurationRoot(List Repositories);
\ No newline at end of file
diff --git a/src/Alma.Abstraction/Data/Constants.cs b/src/Alma.Abstraction/Data/Constants.cs
new file mode 100644
index 0000000..4b0eaef
--- /dev/null
+++ b/src/Alma.Abstraction/Data/Constants.cs
@@ -0,0 +1,6 @@
+namespace Alma.Data;
+
+public static class Constants
+{
+ public static readonly string ModuleConfigFileStub = ".alma-config";
+}
\ No newline at end of file
diff --git a/src/Alma.Abstraction/Data/ItemToLink.cs b/src/Alma.Abstraction/Data/ItemToLink.cs
new file mode 100644
index 0000000..a2fc7e2
--- /dev/null
+++ b/src/Alma.Abstraction/Data/ItemToLink.cs
@@ -0,0 +1,3 @@
+namespace Alma.Data;
+
+public record ItemToLink(string SourcePath, string TargetPath);
\ No newline at end of file
diff --git a/src/Alma.Abstraction/Services/IConfigurationFileReader.cs b/src/Alma.Abstraction/Services/IConfigurationFileReader.cs
new file mode 100644
index 0000000..5be72d5
--- /dev/null
+++ b/src/Alma.Abstraction/Services/IConfigurationFileReader.cs
@@ -0,0 +1,6 @@
+namespace Alma.Services;
+
+public interface IConfigurationFileReader
+{
+ public Task<(T? Result, string? FileName)> DeserializeAsync(string fileNameWithoutExtension, string? extension = null) where T : class;
+}
\ No newline at end of file
diff --git a/src/Alma.Abstraction/Services/IFolderService.cs b/src/Alma.Abstraction/Services/IFolderService.cs
new file mode 100644
index 0000000..80badd0
--- /dev/null
+++ b/src/Alma.Abstraction/Services/IFolderService.cs
@@ -0,0 +1,7 @@
+namespace Alma.Services;
+
+public interface IFolderService
+{
+ string? ConfigRoot { get; }
+ string AppData { get; }
+}
\ No newline at end of file
diff --git a/src/Alma.Abstraction/Services/IMetadataHandler.cs b/src/Alma.Abstraction/Services/IMetadataHandler.cs
new file mode 100644
index 0000000..64fa4e8
--- /dev/null
+++ b/src/Alma.Abstraction/Services/IMetadataHandler.cs
@@ -0,0 +1,8 @@
+using Alma.Data;
+
+namespace Alma.Services;
+
+public interface IMetadataHandler
+{
+ Task SaveLinkedItemsAsync(List successfulLinks, DirectoryInfo sourceDirectory, DirectoryInfo targetDirectory);
+}
\ No newline at end of file
diff --git a/src/Alma.Abstraction/Services/IModuleConfigurationResolver.cs b/src/Alma.Abstraction/Services/IModuleConfigurationResolver.cs
new file mode 100644
index 0000000..5508439
--- /dev/null
+++ b/src/Alma.Abstraction/Services/IModuleConfigurationResolver.cs
@@ -0,0 +1,8 @@
+using Alma.Configuration.Module;
+
+namespace Alma.Services;
+
+public interface IModuleConfigurationResolver
+{
+ Task<(ModuleConfiguration? mergedModuleConfig, string? moduleConfigFileName)> ResolveModuleConfiguration(string moduleConfigStub);
+}
\ No newline at end of file
diff --git a/src/Alma.Abstraction/Services/IOsInformation.cs b/src/Alma.Abstraction/Services/IOsInformation.cs
new file mode 100644
index 0000000..6dd7429
--- /dev/null
+++ b/src/Alma.Abstraction/Services/IOsInformation.cs
@@ -0,0 +1,7 @@
+namespace Alma.Services;
+
+public interface IOsInformation
+{
+ string GetOsIdentifier();
+ bool IsOnPlatform(string platform);
+}
\ No newline at end of file
diff --git a/src/Alma.App/Alma.App.csproj b/src/Alma.App/Alma.App.csproj
new file mode 100644
index 0000000..8ffd592
--- /dev/null
+++ b/src/Alma.App/Alma.App.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+ net6.0
+ enable
+ enable
+ Alma
+
+
+
diff --git a/src/Alma.App/Application.cs b/src/Alma.App/Application.cs
new file mode 100644
index 0000000..05e147b
--- /dev/null
+++ b/src/Alma.App/Application.cs
@@ -0,0 +1,37 @@
+using Alma.Command;
+using Alma.Command.Help;
+
+namespace Alma;
+
+public class Application
+{
+ private readonly IList _commands;
+
+ public Application(IEnumerable commands)
+ {
+ _commands = commands.Append(new HelpCommand(() => _commands!)).ToList();
+ }
+
+ public async Task Run(string[] args)
+ {
+ if (args.Length == 0)
+ {
+ Console.WriteLine("No command was given");
+ return;
+ }
+
+ var commandString = args[0];
+
+ var command = _commands.FirstOrDefault(c => c.CommandString == commandString);
+
+ if (command is null)
+ {
+ Console.WriteLine($"Invalid command: {commandString}");
+ return;
+ }
+
+ await command.Run(args[1..].ToList());
+
+ return;
+ }
+}
\ No newline at end of file
diff --git a/src/Alma.App/Command/Help/HelpCommand.cs b/src/Alma.App/Command/Help/HelpCommand.cs
new file mode 100644
index 0000000..8962af2
--- /dev/null
+++ b/src/Alma.App/Command/Help/HelpCommand.cs
@@ -0,0 +1,24 @@
+namespace Alma.Command.Help;
+
+public class HelpCommand : ICommand
+{
+ private readonly Func> _commandsProvider;
+ public string CommandString => "help";
+
+ public HelpCommand(Func> commandsProvider)
+ {
+ _commandsProvider = commandsProvider;
+ }
+
+ public Task Run(List parameters)
+ {
+ Console.WriteLine("Commands:" + Environment.NewLine);
+
+ foreach (var command in _commandsProvider().OrderBy(c => c.CommandString))
+ {
+ Console.WriteLine(command.CommandString);
+ }
+
+ return Task.CompletedTask;
+ }
+}
\ No newline at end of file
diff --git a/src/Alma.App/Command/Info/ModuleInfoCommand.cs b/src/Alma.App/Command/Info/ModuleInfoCommand.cs
new file mode 100644
index 0000000..f2ca00b
--- /dev/null
+++ b/src/Alma.App/Command/Info/ModuleInfoCommand.cs
@@ -0,0 +1,10 @@
+namespace Alma.Command.Info;
+
+public class ModuleInfoCommand : ICommand
+{
+ public string CommandString => "info";
+ public Task Run(List parameters)
+ {
+ throw new NotImplementedException();
+ }
+}
\ No newline at end of file
diff --git a/src/Alma.App/Command/Link/LinkCommand.cs b/src/Alma.App/Command/Link/LinkCommand.cs
new file mode 100644
index 0000000..6ce2272
--- /dev/null
+++ b/src/Alma.App/Command/Link/LinkCommand.cs
@@ -0,0 +1,193 @@
+using Alma.Configuration.Module;
+using Alma.Configuration.Repository;
+using Alma.Data;
+using Alma.Services;
+
+namespace Alma.Command.Link;
+
+public class LinkCommand : ICommand
+{
+ private readonly IRepositoryConfiguration _repositoryConfiguration;
+ private readonly IModuleConfigurationResolver _moduleConfigurationResolver;
+ private readonly IFolderService _folderService;
+ private readonly IMetadataHandler _metadataHandler;
+ public string CommandString => "link";
+
+ public LinkCommand(
+ IRepositoryConfiguration repositoryConfiguration,
+ IModuleConfigurationResolver moduleConfigurationResolver,
+ IFolderService folderService,
+ IMetadataHandler metadataHandler)
+ {
+ _repositoryConfiguration = repositoryConfiguration;
+ _moduleConfigurationResolver = moduleConfigurationResolver;
+ _folderService = folderService;
+ _metadataHandler = metadataHandler;
+ }
+
+ public async Task Run(List parameters)
+ {
+ if (parameters.Count == 0)
+ {
+ Console.WriteLine("No module specified");
+ return;
+ }
+
+ string moduleName = parameters[0];
+
+ string sourceDirectory = Path.Combine(Environment.CurrentDirectory);
+ string targetDirectory = Path.Combine(Environment.CurrentDirectory, "..");
+
+ var repoName = GetRepositoryName(parameters);
+ if (repoName is not null
+ && _repositoryConfiguration.Configuration.Repositories.FirstOrDefault(r => r.Name == repoName) is { } repoConfig)
+ {
+ sourceDirectory = repoConfig.RepositoryPath ?? sourceDirectory;
+ targetDirectory = repoConfig.LinkPath ?? targetDirectory;
+ }
+
+ if (!Directory.Exists(sourceDirectory))
+ {
+ Console.WriteLine("Source directory not exists: " + sourceDirectory);
+ return;
+ }
+
+ string moduleNameAsPath = moduleName.Replace('/', Path.DirectorySeparatorChar);
+ string moduleDirectory = Path.Combine(sourceDirectory, moduleNameAsPath);
+
+ if (!Directory.Exists(moduleDirectory))
+ {
+ Console.WriteLine("Module directory not exists: " + moduleDirectory);
+ return;
+ }
+
+ var moduleConfigFileStub = Path.Combine(moduleDirectory, Constants.ModuleConfigFileStub);
+ var (moduleConfiguration, moduleConfigurationFile) = await _moduleConfigurationResolver.ResolveModuleConfiguration(moduleConfigFileStub);
+
+ if (moduleConfiguration?.Target is string moduleTargetDir)
+ {
+ targetDirectory = ResolvePath(moduleTargetDir, targetDirectory);
+ }
+
+ if (!Directory.Exists(targetDirectory))
+ {
+ Directory.CreateDirectory(targetDirectory);
+ }
+
+ var moduleConfigurationFileFullPath = moduleConfigurationFile is null ? null : Path.Combine(moduleDirectory, moduleConfigurationFile);
+
+ var moduleDir = new DirectoryInfo(moduleDirectory);
+ var currentTargetDirectory = new DirectoryInfo(targetDirectory);
+ var itemsToLink = (await TraverseTree(
+ moduleDir,
+ currentTargetDirectory,
+ moduleDir,
+ currentTargetDirectory,
+ moduleConfiguration)).ToList();
+ if (moduleConfigurationFile is not null) itemsToLink.RemoveAll(i => i.SourcePath == moduleConfigurationFileFullPath);
+
+ foreach (var itemToLink in itemsToLink)
+ {
+ Console.WriteLine($"Link: '{itemToLink.SourcePath}' '{itemToLink.TargetPath}'");
+ }
+
+ var successfulLinks = CreateLinks(itemsToLink);
+
+ await _metadataHandler.SaveLinkedItemsAsync(successfulLinks, moduleDir, currentTargetDirectory);
+ //await _metadataHandler.SaveLinkedItemsAsync(itemsToLink, moduleDir, currentTargetDirectory);
+ }
+
+ private List CreateLinks(List itemsToLink)
+ {
+ var successfulLinks = new List();
+
+ foreach (var itemToLink in itemsToLink)
+ {
+ if (File.Exists(itemToLink.TargetPath) || Directory.Exists(itemToLink.TargetPath))
+ {
+ Console.WriteLine("Item already exists: " + itemToLink.TargetPath);
+ continue;
+ }
+
+ var sourceFileExists = File.Exists(itemToLink.SourcePath);
+ var sourceDirectoryExists = Directory.Exists(itemToLink.SourcePath);
+
+ if (sourceFileExists)
+ {
+ File.CreateSymbolicLink(itemToLink.TargetPath, itemToLink.SourcePath);
+ }
+ else if (sourceDirectoryExists)
+ {
+ File.CreateSymbolicLink(itemToLink.TargetPath, itemToLink.SourcePath);
+ }
+ else
+ {
+ Console.WriteLine("Source not exists: " + itemToLink.SourcePath);
+ continue;
+ }
+
+ successfulLinks.Add(itemToLink);
+ }
+
+ return successfulLinks;
+ }
+
+ private async Task> TraverseTree(
+ DirectoryInfo currentDirectory,
+ DirectoryInfo currentTargetDirectory,
+ DirectoryInfo moduleDirectory,
+ DirectoryInfo targetDirectory,
+ ModuleConfiguration? moduleConfiguration)
+ {
+ var filesToLink = new List();
+ foreach (var file in currentDirectory.GetFiles())
+ {
+ filesToLink.Add(new ItemToLink(Path.Combine(currentDirectory.FullName, file.Name), Path.Combine(currentTargetDirectory.FullName, file.Name)));
+ }
+
+ var subDirLinksToAdd = Enumerable.Empty();
+
+ foreach (var subDir in currentDirectory.GetDirectories())
+ {
+ var relativePath = GetRelativePath(subDir.FullName, moduleDirectory.FullName);
+ if (moduleConfiguration?.Links?.ContainsKey(relativePath) ?? false)
+ {
+ filesToLink.Add(new ItemToLink(subDir.FullName, ResolvePath(moduleConfiguration.Links[relativePath], targetDirectory.FullName)));
+ }
+ else
+ {
+ var subDirLinks = await TraverseTree(
+ subDir,
+ new DirectoryInfo(Path.Combine(currentTargetDirectory.FullName, subDir.Name)),
+ moduleDirectory,
+ targetDirectory,
+ moduleConfiguration
+ );
+ subDirLinksToAdd = subDirLinksToAdd.Concat(subDirLinks);
+ }
+ }
+
+ return filesToLink.Concat(subDirLinksToAdd);
+ }
+
+ private static string? GetRepositoryName(List parameters)
+ {
+ //TODO: handle parameters
+ if (parameters.Count < 2) return null;
+ return parameters[1];
+ }
+
+ private static string ResolvePath(string path, string currentDirectory)
+ {
+ if (path.StartsWith("~"))
+ {
+ path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), path.Substring(2));
+ }
+
+ //TODO: more special character
+
+ return Path.Combine(currentDirectory, path);
+ }
+
+ private static string GetRelativePath(string full, string parent) => full.Substring(parent.Length).TrimStart(Path.DirectorySeparatorChar);
+}
\ No newline at end of file
diff --git a/src/Alma.App/Command/List/ListCommand.cs b/src/Alma.App/Command/List/ListCommand.cs
new file mode 100644
index 0000000..acc845a
--- /dev/null
+++ b/src/Alma.App/Command/List/ListCommand.cs
@@ -0,0 +1,90 @@
+using Alma.Configuration.Repository;
+using Alma.Data;
+using Alma.Services;
+
+namespace Alma.Command.List;
+
+public class ListCommand : ICommand
+{
+ private readonly IRepositoryConfiguration _repositoryConfiguration;
+ private readonly IModuleConfigurationResolver _moduleConfigurationResolver;
+ public string CommandString => "ls";
+
+ public ListCommand(
+ IRepositoryConfiguration repositoryConfiguration,
+ IModuleConfigurationResolver moduleConfigurationResolver)
+ {
+ _repositoryConfiguration = repositoryConfiguration;
+ _moduleConfigurationResolver = moduleConfigurationResolver;
+ }
+
+ public async Task Run(List parameters)
+ {
+ if (parameters.Count > 0)
+ {
+ await ListModulesByRepoName(parameters[0]);
+ }
+ else
+ {
+ await ListRepositories();
+ }
+ }
+
+ private async Task ListRepositories()
+ {
+ Console.WriteLine("Repositories:" + Environment.NewLine);
+ foreach (var repository in _repositoryConfiguration.Configuration.Repositories)
+ {
+ Console.WriteLine(repository.Name);
+ }
+ }
+
+ private async Task ListModulesByRepoName(string repositoryName)
+ {
+ var repo = _repositoryConfiguration.Configuration.Repositories.FirstOrDefault(r => r.Name == repositoryName);
+ if (repo is null)
+ {
+ Console.WriteLine($"No repository found with name '{repositoryName}'");
+ return;
+ }
+
+ if (repo.RepositoryPath is null)
+ {
+ Console.WriteLine($"No repository path is specified in repository settings '{repositoryName}'");
+ return;
+ }
+
+ await ListModules(repo.RepositoryPath, repositoryName);
+ }
+
+ private async Task ListModules(string repositoryPath, string repositoryName)
+ {
+ var repositoryDirectory = new DirectoryInfo(repositoryPath);
+ var moduleDirectories = await TraverseRepositoryFolder(repositoryDirectory);
+
+ Console.WriteLine($"Modules in repository '{repositoryName}':" + Environment.NewLine);
+ foreach (var modulePath in moduleDirectories)
+ {
+ Console.WriteLine(modulePath.FullName.Substring(repositoryDirectory.FullName.Length).Replace(Path.DirectorySeparatorChar, '/'));
+ }
+ }
+
+ private async Task> TraverseRepositoryFolder(DirectoryInfo currentDirectory)
+ {
+ var moduleConfigFileStub = Path.Combine(currentDirectory.FullName, Constants.ModuleConfigFileStub);
+ var (moduleConfiguration, moduleConfigurationFile) = await _moduleConfigurationResolver.ResolveModuleConfiguration(moduleConfigFileStub);
+
+ var result = Enumerable.Empty();
+ if (moduleConfigurationFile is not null)
+ {
+ result = new List {currentDirectory};
+ }
+
+ foreach (var subDir in currentDirectory.GetDirectories())
+ {
+ result = result.Concat(await TraverseRepositoryFolder(subDir));
+ }
+
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/src/Alma.App/Command/Unlink/UnlinkCommand.cs b/src/Alma.App/Command/Unlink/UnlinkCommand.cs
new file mode 100644
index 0000000..48e97ef
--- /dev/null
+++ b/src/Alma.App/Command/Unlink/UnlinkCommand.cs
@@ -0,0 +1,10 @@
+namespace Alma.Command.Unlink;
+
+public class UnlinkCommand : ICommand
+{
+ public string CommandString => "unlink";
+ public Task Run(List parameters)
+ {
+ throw new NotImplementedException();
+ }
+}
\ No newline at end of file
diff --git a/src/Alma.App/Configuration/Repository/RepositoryConfiguration.cs b/src/Alma.App/Configuration/Repository/RepositoryConfiguration.cs
new file mode 100644
index 0000000..c6b29db
--- /dev/null
+++ b/src/Alma.App/Configuration/Repository/RepositoryConfiguration.cs
@@ -0,0 +1,38 @@
+using Alma.Services;
+
+namespace Alma.Configuration.Repository;
+
+public class RepositoryConfiguration : IRepositoryConfiguration
+{
+ private readonly IFolderService _folderService;
+ private readonly ConfigurationFileReader _configurationFileReader;
+
+ public RepositoryConfigurationRoot Configuration { get; private set; } = new RepositoryConfigurationRoot(new List());
+
+ public RepositoryConfiguration(IFolderService folderService, ConfigurationFileReader configurationFileReader)
+ {
+ _folderService = folderService;
+ _configurationFileReader = configurationFileReader;
+ }
+
+ public async Task LoadAsync()
+ {
+ if (_folderService.ConfigRoot is null)
+ {
+ Configuration = new RepositoryConfigurationRoot(new List());
+ return;
+ }
+
+ var repoConfigFileNameStub = Path.Combine(_folderService.ConfigRoot, "repository");
+ var (configuration, repoConfigFileName) = await _configurationFileReader.DeserializeAsync(repoConfigFileNameStub);
+ Configuration = configuration ?? new RepositoryConfigurationRoot(new List());
+
+ foreach (var repositoryConfigurationEntry in Configuration.Repositories)
+ {
+ if (repositoryConfigurationEntry.Name is null)
+ {
+ Console.WriteLine($"Entry name is null in {repoConfigFileName}");
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Alma.App/Data/Repository/RepositoryFolder.cs b/src/Alma.App/Data/Repository/RepositoryFolder.cs
new file mode 100644
index 0000000..c2c3e9d
--- /dev/null
+++ b/src/Alma.App/Data/Repository/RepositoryFolder.cs
@@ -0,0 +1,6 @@
+namespace Alma.Data.Repository;
+
+public class RepositoryFolder
+{
+
+}
\ No newline at end of file
diff --git a/src/Alma.App/Helper/MD5Helper.cs b/src/Alma.App/Helper/MD5Helper.cs
new file mode 100644
index 0000000..8d58c87
--- /dev/null
+++ b/src/Alma.App/Helper/MD5Helper.cs
@@ -0,0 +1,19 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace Alma.Helper;
+
+public static class MD5Helper
+{
+ public static string GetMD5Hash(string source)
+ {
+ using var md5Hasher = MD5.Create();
+ var data = md5Hasher.ComputeHash(Encoding.UTF8.GetBytes(source));
+ var sBuilder = new StringBuilder();
+ for (var i = 0; i < data.Length; i++)
+ {
+ sBuilder.Append(data[i].ToString("x2"));
+ }
+ return sBuilder.ToString();
+ }
+}
\ No newline at end of file
diff --git a/src/Alma.App/Services/ConfigurationFileReader.cs b/src/Alma.App/Services/ConfigurationFileReader.cs
new file mode 100644
index 0000000..100fd41
--- /dev/null
+++ b/src/Alma.App/Services/ConfigurationFileReader.cs
@@ -0,0 +1,21 @@
+namespace Alma.Services;
+
+public class ConfigurationFileReader
+{
+ private readonly List _configurationFileReaders;
+
+ public ConfigurationFileReader(IEnumerable configurationFileReaders)
+ {
+ _configurationFileReaders = configurationFileReaders.ToList();
+ }
+
+ public async Task<(T? Result, string? FileName)> DeserializeAsync(string fileNameWithoutExtension, string? extension = null) where T : class
+ {
+ foreach (var configurationFileReader in _configurationFileReaders)
+ {
+ if (await configurationFileReader.DeserializeAsync(fileNameWithoutExtension, extension) is {Result: { }} result) return result;
+ }
+
+ return (null, null);
+ }
+}
\ No newline at end of file
diff --git a/src/Alma.App/Services/FolderService.cs b/src/Alma.App/Services/FolderService.cs
new file mode 100644
index 0000000..af598b1
--- /dev/null
+++ b/src/Alma.App/Services/FolderService.cs
@@ -0,0 +1,51 @@
+namespace Alma.Services;
+
+public class FolderService : IFolderService
+{
+ public string? ConfigRoot { get; }
+ public string AppData { get; }
+
+ public FolderService()
+ {
+ ConfigRoot = GetConfigHomePath();
+ AppData = GetAppDataPath();
+
+ if (!Directory.Exists(AppData)) Directory.CreateDirectory(AppData);
+ }
+
+ private static string? GetConfigHomePath()
+ {
+ var configHomeProviders = new List>
+ {
+ () => Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"),
+ () => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config")
+ };
+
+ var configHome = EnumerateProviders(configHomeProviders);
+ return configHome == null ? null : Path.Combine(configHome, "alma");
+ }
+
+ private static string GetAppDataPath()
+ {
+ var appDataProviders = new List>
+ {
+ () => Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)
+ };
+
+ var appData = EnumerateProviders(appDataProviders) ?? Environment.CurrentDirectory;
+ return Path.Combine(appData, "alma");
+ }
+
+ private static string? EnumerateProviders(List> providers)
+ {
+ string? result = null;
+
+ foreach (var provider in providers)
+ {
+ result = provider();
+ if (result is not null && Directory.Exists(result)) break;
+ }
+
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/src/Alma.App/Services/JsonConfigurationFileReader.cs b/src/Alma.App/Services/JsonConfigurationFileReader.cs
new file mode 100644
index 0000000..10d8a88
--- /dev/null
+++ b/src/Alma.App/Services/JsonConfigurationFileReader.cs
@@ -0,0 +1,18 @@
+using System.Text.Json;
+
+namespace Alma.Services;
+
+public class JsonConfigurationFileReader : IConfigurationFileReader
+{
+ private static readonly JsonSerializerOptions DefaultOptions = new(JsonSerializerDefaults.Web);
+
+ public async Task<(T? Result, string? FileName)> DeserializeAsync(string fileNameWithoutExtension, string? extension) where T : class
+ {
+ extension ??= "json";
+ var fileName = fileNameWithoutExtension + "." + extension;
+ if (!File.Exists(fileName)) return (null, null);
+
+ await using FileStream openStream = File.OpenRead(fileName);
+ return (await JsonSerializer.DeserializeAsync(openStream, DefaultOptions), fileName);
+ }
+}
\ No newline at end of file
diff --git a/src/Alma.App/Services/MetadataHandler.cs b/src/Alma.App/Services/MetadataHandler.cs
new file mode 100644
index 0000000..17f98a1
--- /dev/null
+++ b/src/Alma.App/Services/MetadataHandler.cs
@@ -0,0 +1,66 @@
+using System.Text;
+using Alma.Data;
+using Alma.Helper;
+
+namespace Alma.Services;
+
+public class MetadataHandler : IMetadataHandler
+{
+ private const string MetadataFilename = "moduleHashes.txt";
+ private readonly IFolderService _folderService;
+
+ public MetadataHandler(IFolderService folderService)
+ {
+ _folderService = folderService;
+ }
+
+ public async Task SaveLinkedItemsAsync(List successfulLinks, DirectoryInfo sourceDirectory, DirectoryInfo targetDirectory)
+ {
+ var sourcePathHash = MD5Helper.GetMD5Hash(sourceDirectory.FullName);
+ var targetPathHash = MD5Helper.GetMD5Hash(targetDirectory.FullName);
+ var modulePathHash = MD5Helper.GetMD5Hash(sourcePathHash + targetPathHash);
+ var appDataDirectory = new DirectoryInfo(_folderService.AppData);
+
+ var moduleFolderMetadataPath = Path.Combine(appDataDirectory.FullName, modulePathHash + ".txt");
+
+ var previousData = new List();
+
+ if (File.Exists(moduleFolderMetadataPath))
+ {
+ var content = await File.ReadAllLinesAsync(moduleFolderMetadataPath);
+ previousData.AddRange(content.Skip(1));
+ }
+
+ var newContent = previousData.Concat(successfulLinks.Select(s => s.TargetPath)).Distinct();
+ await File.WriteAllLinesAsync(moduleFolderMetadataPath, newContent.Prepend("text"));
+
+ //TODO write md5 & path to a common file
+ var hashAlreadySaved = false;
+
+ var metadataFilePath = Path.Combine(_folderService.AppData, MetadataFilename);
+ if (File.Exists(metadataFilePath))
+ {
+ await using var metadataFileStream = File.OpenRead(metadataFilePath);
+ using var metadataFileStream2 = new StreamReader(metadataFileStream);
+ while (await metadataFileStream2.ReadLineAsync() is { } s)
+ {
+ if (!s.StartsWith(modulePathHash)) continue;
+
+ hashAlreadySaved = true;
+ break;
+ }
+ }
+
+ if (!hashAlreadySaved)
+ {
+ var newLineContent = modulePathHash + " " + EncodePath(sourceDirectory.FullName) + " " + EncodePath(targetDirectory.FullName);
+ await File.AppendAllLinesAsync(metadataFilePath, new[] {newLineContent});
+ }
+
+ static string EncodePath(string path)
+ {
+ byte[] toEncodeAsBytes = Encoding.UTF8.GetBytes(path);
+ return Convert.ToBase64String(toEncodeAsBytes);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Alma.App/Services/ModuleConfigurationResolver.cs b/src/Alma.App/Services/ModuleConfigurationResolver.cs
new file mode 100644
index 0000000..265f035
--- /dev/null
+++ b/src/Alma.App/Services/ModuleConfigurationResolver.cs
@@ -0,0 +1,35 @@
+using Alma.Configuration.Module;
+
+namespace Alma.Services;
+
+public class ModuleConfigurationResolver : IModuleConfigurationResolver
+{
+ private readonly IConfigurationFileReader _configurationFileReader;
+ private readonly IOsInformation _osInformation;
+
+ public ModuleConfigurationResolver(
+ IConfigurationFileReader configurationFileReader,
+ IOsInformation osInformation)
+ {
+ _configurationFileReader = configurationFileReader;
+ _osInformation = osInformation;
+ }
+
+ public async Task<(ModuleConfiguration? mergedModuleConfig, string? moduleConfigFileName)> ResolveModuleConfiguration(string moduleConfigStub)
+ {
+ var (moduleConfigRoot, moduleConfigFileName) = await _configurationFileReader.DeserializeAsync(moduleConfigStub);
+
+ if (moduleConfigRoot is null) return (null, null);
+
+ var validModuleConfigurations = moduleConfigRoot.Where(m => _osInformation.IsOnPlatform(m.Key));
+
+ //TODO: priority order
+ var orderedValidModuleConfigurations = new Dictionary(validModuleConfigurations);
+
+ var mergedModuleConfig = orderedValidModuleConfigurations
+ .Select(m => m.Value)
+ .Aggregate((a, b) => a.Merge(b));
+
+ return (mergedModuleConfig, moduleConfigFileName);
+ }
+}
\ No newline at end of file
diff --git a/src/Alma.App/Services/OsInformation.cs b/src/Alma.App/Services/OsInformation.cs
new file mode 100644
index 0000000..931c1a6
--- /dev/null
+++ b/src/Alma.App/Services/OsInformation.cs
@@ -0,0 +1,24 @@
+using System.Runtime.InteropServices;
+
+namespace Alma.Services;
+
+public class OsInformation : IOsInformation
+{
+ private const string OsIdentifierDefault = "default";
+ private const string OsIdentifierWin = "windows";
+ private const string OsIdentifierLinux = "linux";
+
+ public string GetOsIdentifier()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return OsIdentifierWin;
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return OsIdentifierLinux;
+
+ return "unknown";
+ }
+
+ public bool IsOnPlatform(string platform)
+ {
+ if (platform == OsIdentifierDefault) return true;
+ return platform == GetOsIdentifier();
+ }
+}
\ No newline at end of file
diff --git a/src/Alma.sln b/src/Alma.sln
new file mode 100644
index 0000000..e3696d2
--- /dev/null
+++ b/src/Alma.sln
@@ -0,0 +1,34 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Alma.Abstraction", "Alma.Abstraction\Alma.Abstraction.csproj", "{49A2563D-8D89-4B2A-A81E-39A50F5B0C25}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Alma.App", "Alma.App\Alma.App.csproj", "{6FBB9920-A249-41AE-8CE2-5D2A4FF3B551}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Alma", "Alma\Alma.csproj", "{23157A6F-C737-4ED4-B36B-BFE3EA31EAF1}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {49A2563D-8D89-4B2A-A81E-39A50F5B0C25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {49A2563D-8D89-4B2A-A81E-39A50F5B0C25}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {49A2563D-8D89-4B2A-A81E-39A50F5B0C25}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {49A2563D-8D89-4B2A-A81E-39A50F5B0C25}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6FBB9920-A249-41AE-8CE2-5D2A4FF3B551}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6FBB9920-A249-41AE-8CE2-5D2A4FF3B551}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6FBB9920-A249-41AE-8CE2-5D2A4FF3B551}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6FBB9920-A249-41AE-8CE2-5D2A4FF3B551}.Release|Any CPU.Build.0 = Release|Any CPU
+ {23157A6F-C737-4ED4-B36B-BFE3EA31EAF1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {23157A6F-C737-4ED4-B36B-BFE3EA31EAF1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {23157A6F-C737-4ED4-B36B-BFE3EA31EAF1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {23157A6F-C737-4ED4-B36B-BFE3EA31EAF1}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/src/Alma/Alma.csproj b/src/Alma/Alma.csproj
new file mode 100644
index 0000000..d6aad34
--- /dev/null
+++ b/src/Alma/Alma.csproj
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+ Exe
+ net6.0
+ enable
+ enable
+
+
+
diff --git a/src/Alma/Program.cs b/src/Alma/Program.cs
new file mode 100644
index 0000000..f4e6c4e
--- /dev/null
+++ b/src/Alma/Program.cs
@@ -0,0 +1,81 @@
+using Alma.Command;
+using Alma.Command.Help;
+using Alma.Command.Info;
+using Alma.Command.Link;
+using Alma.Command.List;
+using Alma.Command.Unlink;
+using Alma.Configuration.Repository;
+using Alma.Services;
+using Jab;
+
+namespace Alma;
+
+public static class Program
+{
+ /*public static async Task Main(string[] args)
+ {
+ var services = BuildServices();
+
+ var repositoryConfiguration = services.GetRequiredService();
+ await repositoryConfiguration.LoadAsync();
+ var application = services.GetRequiredService();
+
+ await application.Run(args);
+
+ static IServiceProvider BuildServices()
+ {
+ var serviceCollection = new ServiceCollection();
+
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+
+ typeof(IRepositoryConfiguration), typeof(RepositoryConfiguration)
+ typeof(IFolderService), typeof(FolderService)
+ typeof(ConfigurationFileReader)
+ typeof(IConfigurationFileReader), typeof(JsonConfigurationFileReader)
+ typeof(IOsInformation), typeof(OsInformation)
+ typeof(ICommand), typeof(LinkCommand)
+ typeof(IModuleConfigurationResolver), typeof(ModuleConfigurationResolver)
+ typeof(Application)
+
+ return serviceCollection.BuildServiceProvider();
+ }
+ }*/
+
+ public static async Task Main(string[] args)
+ {
+ var services = new AlmaServiceProvider();
+
+ var repositoryConfiguration = services.GetService();
+ await repositoryConfiguration.LoadAsync();
+ var application = services.GetService();
+
+ await application.Run(args);
+ }
+}
+
+[ServiceProvider]
+[Singleton(typeof(IRepositoryConfiguration), typeof(RepositoryConfiguration))]
+[Singleton(typeof(IFolderService), typeof(FolderService))]
+[Singleton(typeof(ConfigurationFileReader))]
+[Singleton(typeof(IConfigurationFileReader), typeof(JsonConfigurationFileReader))]
+[Singleton(typeof(IOsInformation), typeof(OsInformation))]
+[Singleton(typeof(ICommand), typeof(LinkCommand))]
+[Singleton(typeof(ICommand), typeof(UnlinkCommand))]
+[Singleton(typeof(ICommand), typeof(ModuleInfoCommand))]
+[Singleton(typeof(ICommand), typeof(ListCommand))]
+//Dependency cycle
+//[Singleton(typeof(ICommand), typeof(HelpCommand))]
+[Singleton(typeof(IModuleConfigurationResolver), typeof(ModuleConfigurationResolver))]
+[Singleton(typeof(IMetadataHandler), typeof(MetadataHandler))]
+[Singleton(typeof(Application))]
+internal partial class AlmaServiceProvider
+{
+
+}
\ No newline at end of file