2025-05-30 22:39:16 +02:00
using System.Diagnostics ;
using System.Reflection ;
using AIStudio.Tools.Metadata ;
2026-08-16 12:18:53 +02:00
using AIStudio.Tools.Rust ;
2025-05-30 22:39:16 +02:00
using AIStudio.Tools.Services ;
using SharedTools ;
namespace AIStudio.Tools ;
public sealed class PandocProcessBuilder
{
private static readonly Assembly ASSEMBLY = Assembly . GetExecutingAssembly ();
private static readonly MetaDataArchitectureAttribute META_DATA_ARCH = ASSEMBLY . GetCustomAttribute < MetaDataArchitectureAttribute >()!;
2026-01-20 10:34:04 +01:00
// Use runtime detection instead of metadata to ensure correct RID on dev machines:
private static readonly RID CPU_ARCHITECTURE = RIDExtensions . GetCurrentRID ();
private static readonly RID METADATA_ARCHITECTURE = META_DATA_ARCH . Architecture . ToRID ();
private static readonly ILogger LOGGER = Program . LOGGER_FACTORY . CreateLogger ( nameof ( PandocProcessBuilder ));
2026-06-11 12:33:19 +02:00
private const string FLATPAK_PANDOC_PLUGIN_BIN_DIRECTORY = "/app/plugins/pandoc/bin" ;
2026-01-20 10:34:04 +01:00
// Tracks whether the first log has been written to avoid log spam on repeated calls:
private static bool HAS_LOGGED_ONCE ;
2025-05-30 22:39:16 +02:00
private string? providedInputFile ;
private string? providedOutputFile ;
private string? providedInputFormat ;
private string? providedOutputFormat ;
2025-11-11 15:30:17 +01:00
private bool useStandaloneMode ;
2025-05-30 22:39:16 +02:00
private readonly List < string > additionalArguments = new ();
private PandocProcessBuilder ()
{
}
public static PandocProcessBuilder Create () => new ();
public PandocProcessBuilder WithInputFile ( string inputFile )
{
this . providedInputFile = inputFile ;
return this ;
}
public PandocProcessBuilder WithOutputFile ( string outputFile )
{
this . providedOutputFile = outputFile ;
return this ;
}
public PandocProcessBuilder WithInputFormat ( string inputFormat )
{
this . providedInputFormat = inputFormat ;
return this ;
}
public PandocProcessBuilder WithOutputFormat ( string outputFormat )
{
this . providedOutputFormat = outputFormat ;
return this ;
}
public PandocProcessBuilder AddArgument ( string argument )
{
this . additionalArguments . Add ( argument );
return this ;
}
2025-11-11 15:30:17 +01:00
public PandocProcessBuilder UseStandaloneMode ()
{
this . useStandaloneMode = true ;
return this ;
}
2025-05-30 22:39:16 +02:00
public async Task < PandocPreparedProcess > BuildAsync ( RustService rustService )
{
var pandocExecutable = await PandocExecutablePath ( rustService );
2026-02-20 14:13:10 +01:00
var startInfo = new ProcessStartInfo
2025-05-30 22:39:16 +02:00
{
FileName = pandocExecutable . Executable ,
RedirectStandardOutput = true ,
RedirectStandardError = true ,
UseShellExecute = false ,
CreateNoWindow = true
2026-02-20 14:13:10 +01:00
};
// Use argument tokens instead of a single command string so paths with spaces
// or Unicode characters are passed to Pandoc unchanged on all platforms.
if ( this . useStandaloneMode )
startInfo . ArgumentList . Add ( "--standalone" );
if (! string . IsNullOrWhiteSpace ( this . providedInputFile ))
startInfo . ArgumentList . Add ( this . providedInputFile );
if (! string . IsNullOrWhiteSpace ( this . providedInputFormat ))
{
startInfo . ArgumentList . Add ( "-f" );
startInfo . ArgumentList . Add ( this . providedInputFormat );
}
if (! string . IsNullOrWhiteSpace ( this . providedOutputFormat ))
{
startInfo . ArgumentList . Add ( "-t" );
startInfo . ArgumentList . Add ( this . providedOutputFormat );
}
foreach ( var additionalArgument in this . additionalArguments )
{
if (! string . IsNullOrWhiteSpace ( additionalArgument ))
startInfo . ArgumentList . Add ( additionalArgument );
}
if (! string . IsNullOrWhiteSpace ( this . providedOutputFile ))
{
startInfo . ArgumentList . Add ( "-o" );
startInfo . ArgumentList . Add ( this . providedOutputFile );
}
return new ( startInfo , pandocExecutable . IsLocalInstallation );
2025-05-30 22:39:16 +02:00
}
/// <summary>
/// Returns the path to the pandoc executable.
/// </summary>
/// <remarks>
/// Any local installation of pandoc will be preferred over the system-wide installation.
/// When a local installation is found, its absolute path will be returned. In case no local
/// installation is found, the name of the pandoc executable will be returned.
/// </remarks>
/// <param name="rustService">Global rust service to access file system and data dir.</param>
/// <returns>Path to the pandoc executable.</returns>
private static async Task < PandocExecutable > PandocExecutablePath ( RustService rustService )
{
//
2026-01-20 10:34:04 +01:00
// Determine if we should log (only on the first call):
2025-05-30 22:39:16 +02:00
//
2026-01-20 10:34:04 +01:00
var shouldLog = ! HAS_LOGGED_ONCE ;
2025-05-30 22:39:16 +02:00
try
{
2026-01-20 10:34:04 +01:00
//
// Log a warning if the runtime-detected RID differs from the metadata RID.
// This can happen on dev machines where the metadata.txt contains stale values.
// We always use the runtime-detected RID for correct behavior.
//
if ( shouldLog && CPU_ARCHITECTURE != METADATA_ARCHITECTURE )
{
LOGGER . LogWarning (
"Runtime-detected RID '{RuntimeRID}' differs from metadata RID '{MetadataRID}'. Using runtime-detected RID. This is expected on dev machines where metadata.txt may be outdated." ,
CPU_ARCHITECTURE . ToUserFriendlyName (),
METADATA_ARCHITECTURE . ToUserFriendlyName ());
}
//
// First, we try to find the pandoc executable in the data directory.
// Any local installation should be preferred over the system-wide installation.
//
var localInstallationRootDirectory = await Pandoc . GetPandocDataFolder ( rustService );
//
// Check if the data directory path is valid:
//
if ( string . IsNullOrWhiteSpace ( localInstallationRootDirectory ))
{
if ( shouldLog )
LOGGER . LogWarning ( "The local data directory path is empty or null. Cannot search for local Pandoc installation." );
}
else if (! Directory . Exists ( localInstallationRootDirectory ))
2025-05-30 22:39:16 +02:00
{
2026-01-20 10:34:04 +01:00
if ( shouldLog )
LOGGER . LogWarning ( "The local Pandoc installation directory does not exist: '{LocalInstallationRootDirectory}'." , localInstallationRootDirectory );
2025-05-30 22:39:16 +02:00
}
2026-01-20 10:34:04 +01:00
else
{
//
// The directory exists, search for the pandoc executable:
//
var executableName = PandocExecutableName ;
if ( shouldLog )
LOGGER . LogInformation ( "Searching for Pandoc executable '{ExecutableName}' in: '{LocalInstallationRootDirectory}'." , executableName , localInstallationRootDirectory );
try
{
//
// First, check the root directory itself:
//
var rootExecutablePath = Path . Combine ( localInstallationRootDirectory , executableName );
if ( File . Exists ( rootExecutablePath ))
{
if ( shouldLog )
LOGGER . LogInformation ( "Found local Pandoc installation at the root path: '{Path}'." , rootExecutablePath );
HAS_LOGGED_ONCE = true ;
return new ( rootExecutablePath , true );
}
//
// Then, search all subdirectories:
//
var subdirectories = Directory . GetDirectories ( localInstallationRootDirectory , "*" , SearchOption . AllDirectories );
foreach ( var subdirectory in subdirectories )
{
var pandocPath = Path . Combine ( subdirectory , executableName );
if ( File . Exists ( pandocPath ))
{
if ( shouldLog )
LOGGER . LogInformation ( "Found local Pandoc installation at: '{Path}'." , pandocPath );
HAS_LOGGED_ONCE = true ;
return new ( pandocPath , true );
}
}
if ( shouldLog )
LOGGER . LogWarning ( "No Pandoc executable found in local installation directory or its subdirectories." );
}
catch ( Exception ex )
{
2026-08-10 16:37:44 +02:00
//
// Always logged, in contrast to the lines above: those describe a stable setup,
// while this one is a transient fault, e.g. an unreachable data directory on a
// network drive. Suppressing repeats would hide it after the first call.
//
LOGGER . LogWarning ( ex , "Error while searching for a local Pandoc installation in: '{LocalInstallationRootDirectory}'." , localInstallationRootDirectory );
2026-01-20 10:34:04 +01:00
}
}
2026-06-11 12:33:19 +02:00
var runtimeInfo = await rustService . GetRuntimeInfo ();
foreach ( var candidate in SystemPandocExecutableCandidates ( PandocExecutableName , runtimeInfo . LinuxPackageType ))
2026-05-16 18:27:16 +02:00
{
if (! File . Exists ( candidate ))
continue ;
if ( shouldLog )
LOGGER . LogInformation ( "Found system Pandoc installation at: '{Path}'." , candidate );
return new ( candidate , false );
}
2026-01-20 10:34:04 +01:00
//
// When no local installation was found, we assume that the pandoc executable is in the system PATH:
//
if ( shouldLog )
LOGGER . LogWarning ( "Falling back to system PATH for the Pandoc executable: '{ExecutableName}'." , PandocExecutableName );
return new ( PandocExecutableName , false );
2025-05-30 22:39:16 +02:00
}
2026-01-20 10:34:04 +01:00
finally
2025-05-30 22:39:16 +02:00
{
2026-01-20 10:34:04 +01:00
HAS_LOGGED_ONCE = true ;
2025-05-30 22:39:16 +02:00
}
}
/// <summary>
/// Reads the os platform to determine the used executable name.
/// </summary>
public static string PandocExecutableName => CPU_ARCHITECTURE is RID . WIN_ARM64 or RID . WIN_X64 ? "pandoc.exe" : "pandoc" ;
2026-05-16 18:27:16 +02:00
2026-08-16 12:18:53 +02:00
private static IEnumerable < string > SystemPandocExecutableCandidates ( string executableName , LinuxPackageType linuxPackageType )
2026-05-16 18:27:16 +02:00
{
var candidates = new List < string >();
switch ( CPU_ARCHITECTURE )
{
case RID . WIN_X64 or RID . WIN_ARM64 :
AddCandidate ( candidates , Environment . GetFolderPath ( Environment . SpecialFolder . LocalApplicationData ), "Pandoc" , executableName );
AddCandidate ( candidates , Environment . GetFolderPath ( Environment . SpecialFolder . ProgramFiles ), "Pandoc" , executableName );
AddCandidate ( candidates , Environment . GetFolderPath ( Environment . SpecialFolder . ProgramFilesX86 ), "Pandoc" , executableName );
break ;
case RID . OSX_X64 or RID . OSX_ARM64 :
AddCandidate ( candidates , "/opt/homebrew/bin" , executableName );
AddCandidate ( candidates , "/usr/local/bin" , executableName );
AddCandidate ( candidates , "/usr/bin" , executableName );
break ;
case RID . LINUX_X64 or RID . LINUX_ARM64 :
2026-08-16 12:18:53 +02:00
if ( linuxPackageType is LinuxPackageType . FLATPAK )
2026-06-11 12:33:19 +02:00
AddCandidate ( candidates , FLATPAK_PANDOC_PLUGIN_BIN_DIRECTORY , executableName );
2026-05-16 18:27:16 +02:00
AddCandidate ( candidates , "/usr/local/bin" , executableName );
AddCandidate ( candidates , "/usr/bin" , executableName );
AddCandidate ( candidates , "/snap/bin" , executableName );
var homeDirectory = Environment . GetFolderPath ( Environment . SpecialFolder . UserProfile );
AddCandidate ( candidates , homeDirectory , ".local" , "bin" , executableName );
break ;
}
foreach ( var pathDirectory in GetPathDirectories ())
AddCandidate ( candidates , pathDirectory , executableName );
var comparer = CPU_ARCHITECTURE is RID . WIN_X64 or RID . WIN_ARM64
? StringComparer . OrdinalIgnoreCase
: StringComparer . Ordinal ;
return candidates . Distinct ( comparer );
}
private static IEnumerable < string > GetPathDirectories ()
{
var pathValue = Environment . GetEnvironmentVariable ( "PATH" );
if ( string . IsNullOrWhiteSpace ( pathValue ))
yield break ;
foreach ( var pathDirectory in pathValue . Split ( Path . PathSeparator , StringSplitOptions . RemoveEmptyEntries | StringSplitOptions . TrimEntries ))
yield return pathDirectory ;
}
private static void AddCandidate ( List < string > candidates , params string [] pathParts )
{
if ( pathParts . Any ( string . IsNullOrWhiteSpace ))
return ;
candidates . Add ( Path . Combine ( pathParts ));
}
2025-05-30 22:39:16 +02:00
}