diff --git a/pgLabII.PgUtils.Tests/ConnectionStrings/ConnectionStringServiceTests.cs b/pgLabII.PgUtils.Tests/ConnectionStrings/ConnectionStringServiceTests.cs
new file mode 100644
index 0000000..4342c08
--- /dev/null
+++ b/pgLabII.PgUtils.Tests/ConnectionStrings/ConnectionStringServiceTests.cs
@@ -0,0 +1,73 @@
+using pgLabII.PgUtils.ConnectionStrings;
+
+namespace pgLabII.PgUtils.Tests.ConnectionStrings;
+
+public class ConnectionStringServiceTests
+{
+ private readonly ConnectionStringService svc = ConnectionStringService.CreateDefault();
+
+ [Fact]
+ public void DetectFormat_Url()
+ {
+ var r = svc.DetectFormat("postgresql://user@localhost/db");
+ Assert.True(r.IsSuccess);
+ Assert.Equal(ConnStringFormat.Url, r.Value);
+ }
+
+ [Fact]
+ public void DetectFormat_Npgsql()
+ {
+ var r = svc.DetectFormat("Host=localhost;Database=db;Username=u");
+ Assert.True(r.IsSuccess);
+ Assert.Equal(ConnStringFormat.Npgsql, r.Value);
+ }
+
+ [Fact]
+ public void DetectFormat_Libpq()
+ {
+ var r = svc.DetectFormat("host=localhost dbname=db user=u");
+ Assert.True(r.IsSuccess);
+ Assert.Equal(ConnStringFormat.Libpq, r.Value);
+ }
+
+ [Fact]
+ public void ParseToDescriptor_DispatchesByFormat()
+ {
+ var r1 = svc.ParseToDescriptor("host=localhost dbname=db user=u");
+ Assert.True(r1.IsSuccess);
+ var r2 = svc.ParseToDescriptor("Host=localhost;Database=db;Username=u");
+ Assert.True(r2.IsSuccess);
+ var r3 = svc.ParseToDescriptor("postgresql://u@localhost/db");
+ Assert.True(r3.IsSuccess);
+ }
+
+ [Fact]
+ public void Convert_Libpq_to_Npgsql()
+ {
+ var input = "host=localhost port=5433 dbname=mydb user=alice password=secret sslmode=require";
+ var r = svc.Convert(input, ConnStringFormat.Npgsql);
+ Assert.True(r.IsSuccess);
+ var s = r.Value;
+ Assert.Contains("Host=localhost", s);
+ Assert.Contains("Port=5433", s);
+ Assert.Contains("Database=mydb", s);
+ Assert.Contains("Username=alice", s);
+ Assert.Contains("Password=secret", s);
+ Assert.Contains("SSL Mode=Require", s);
+ }
+
+ [Fact]
+ public void Convert_Url_to_Libpq()
+ {
+ var input = "postgresql://bob:pwd@host1:5432,host2:5433/db?application_name=cli&sslmode=prefer";
+ var r = svc.Convert(input, ConnStringFormat.Libpq);
+ Assert.True(r.IsSuccess);
+ var s = r.Value;
+ Assert.Contains("host=host1,host2", s);
+ // Ports differ (5432 vs 5433), libpq formatter emits a single port only when all ports are the same
+ Assert.DoesNotContain("port=", s);
+ Assert.Contains("dbname=db", s);
+ Assert.Contains("user=bob", s);
+ Assert.Contains("sslmode=prefer", s);
+ }
+}
diff --git a/pgLabII.PgUtils/ConnectionStrings/ConnectionStringService.cs b/pgLabII.PgUtils/ConnectionStrings/ConnectionStringService.cs
new file mode 100644
index 0000000..11e69b3
--- /dev/null
+++ b/pgLabII.PgUtils/ConnectionStrings/ConnectionStringService.cs
@@ -0,0 +1,90 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using FluentResults;
+
+namespace pgLabII.PgUtils.ConnectionStrings;
+
+///
+/// Composite implementation that composes multiple codecs to detect, parse, format and convert
+/// PostgreSQL connection strings between formats.
+///
+public sealed class ConnectionStringService : IConnectionStringService
+{
+ private readonly Dictionary _codecs;
+ private readonly IConnectionStringCodec[] _all;
+
+ public ConnectionStringService(IEnumerable codecs)
+ {
+ _all = codecs?.ToArray() ?? Array.Empty();
+ _codecs = _all.GroupBy(c => c.Format).ToDictionary(g => g.Key, g => g.First());
+ }
+
+ ///
+ /// Creates a service pre-configured with built-in codecs (Libpq, Npgsql, Url).
+ ///
+ public static ConnectionStringService CreateDefault()
+ => new(new IConnectionStringCodec[] { new LibpqCodec(), new NpgsqlCodec(), new UrlCodec() });
+
+ public Result DetectFormat(string input)
+ {
+ if (string.IsNullOrWhiteSpace(input))
+ return Result.Fail("Empty input");
+
+ // URL: postgresql:// or postgres://
+ var trimmed = input.TrimStart();
+ if (trimmed.StartsWith("postgresql://", StringComparison.OrdinalIgnoreCase) ||
+ trimmed.StartsWith("postgres://", StringComparison.OrdinalIgnoreCase))
+ {
+ return Result.Ok(ConnStringFormat.Url);
+ }
+
+ // Heuristic for Npgsql: presence of ';' separator and typical keys like Host= or Username=
+ // Also many .NET conn strings end with a trailing semicolon but not required.
+ if (trimmed.Contains(';'))
+ {
+ var firstEq = trimmed.IndexOf('=');
+ var firstSemi = trimmed.IndexOf(';');
+ if (firstEq > 0 && (firstSemi > firstEq || firstSemi == -1))
+ {
+ // quick key sample
+ var key = trimmed.Substring(0, firstEq).Trim();
+ if (key.Length > 0)
+ return Result.Ok(ConnStringFormat.Npgsql);
+ }
+ }
+
+ // Fallback to libpq if it looks like space-separated key=value or single pair
+ // Very loose heuristic: contains '=' and either a space-separated sequence or quotes
+ if (trimmed.Contains('='))
+ {
+ return Result.Ok(ConnStringFormat.Libpq);
+ }
+
+ return Result.Fail("Unable to detect format");
+ }
+
+ public Result ParseToDescriptor(string input)
+ {
+ var fmtRes = DetectFormat(input);
+ if (fmtRes.IsFailed) return Result.Fail(fmtRes.Errors);
+ var fmt = fmtRes.Value;
+ if (!_codecs.TryGetValue(fmt, out var codec))
+ return Result.Fail($"No codec registered for {fmt}");
+ return codec.TryParse(input);
+ }
+
+ public Result FormatFromDescriptor(ConnectionDescriptor descriptor, ConnStringFormat targetFormat)
+ {
+ if (!_codecs.TryGetValue(targetFormat, out var codec))
+ return Result.Fail($"No codec registered for {targetFormat}");
+ return codec.TryFormat(descriptor);
+ }
+
+ public Result Convert(string input, ConnStringFormat targetFormat)
+ {
+ var parseRes = ParseToDescriptor(input);
+ if (parseRes.IsFailed) return Result.Fail(parseRes.Errors);
+ return FormatFromDescriptor(parseRes.Value, targetFormat);
+ }
+}