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
139 changes: 139 additions & 0 deletions SabreTools.CommandLine.Test/CommandSetTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using SabreTools.CommandLine.Inputs;
using Xunit;

Expand Down Expand Up @@ -839,6 +840,144 @@ public void ProcessArgs_NestedArgs_Success()

#endregion

#region Manpage Output

[Fact]
public void OutputManpage_Structure_ContainsRequiredSections()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample") { Version = "sample 1.0", Description = "do sample things" };

string page = set.OutputManpage(info);

Assert.Contains(".TH \"SAMPLE\" \"1\"", page);
Assert.Contains(".SH NAME", page);
Assert.Contains("sample \\- do sample things", page);
Assert.Contains(".SH SYNOPSIS", page);
Assert.Contains(".SH DESCRIPTION", page);
Assert.Contains(".SH OPTIONS", page);
Assert.Contains(".TP", page);
}

[Fact]
public void OutputManpage_EscapesHyphensInFlags()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample");

string page = set.OutputManpage(info);

Assert.Contains(".B \\-\\-convert", page);
Assert.DoesNotContain(".B --convert", page);
}

[Fact]
public void OutputManpage_Verbose_TogglesDetailedText()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample");

string terse = set.OutputManpage(info, includeVerbose: false);
string verbose = set.OutputManpage(info, includeVerbose: true);

Assert.DoesNotContain("Built\\-in to most of the programs", terse);
Assert.Contains("Built\\-in to most of the programs", verbose);
}

[Fact]
public void OutputManpage_NestsChildren()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample");

string page = set.OutputManpage(info);

Assert.Contains(".RS", page);
Assert.Contains(".RE", page);
Assert.Contains(".B \\-\\-output", page);
}

[Fact]
public void OutputManpage_HonorsCustomOptionsHeading()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample") { OptionsHeading = "COMMANDS" };

string page = set.OutputManpage(info);

Assert.Contains(".SH COMMANDS", page);
Assert.DoesNotContain(".SH OPTIONS", page);
}

[Fact]
public void OutputManpage_LeavesBlankDateForStamping()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample") { Version = "sample 1.0" };

string page = set.OutputManpage(info);

// Section, then an empty date field, then the version
Assert.Contains(".TH \"SAMPLE\" \"1\" \"\" \"sample 1.0\"", page);
}

[Fact]
public void OutputManpage_LinesWithinWidth()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample") { Description = "do sample things" };

string page = set.OutputManpage(info, includeVerbose: true);

foreach (string line in page.Split('\n'))
{
Assert.True(line.Length <= 78, $"Line exceeds 78 columns: {line}");
}
}

[Fact]
public void OutputManpage_FileOverload_WritesSameContent()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample");

string expected = set.OutputManpage(info);
string path = Path.Combine(Path.GetTempPath(), $"sabretools-manpage-{Guid.NewGuid():N}.1");
try
{
set.OutputManpage(path, info);
string actual = File.ReadAllText(path);
Assert.Equal(expected, actual);
}
finally
{
if (File.Exists(path))
File.Delete(path);
}
}

#endregion

/// <summary>
/// Build a representative command set with nested inputs and detail text
/// </summary>
private static CommandSet BuildSampleSet()
{
var set = new CommandSet("Sample header line");

var help = new MockFeature("Help", ["?", "h", "help"], "Show this help",
"Built-in to most of the programs is a basic help text.");
set.Add(help);

var convert = new MockFeature("Convert", "--convert", "Convert input files",
"Converts the provided input files into the desired output format.");
convert.Add(new StringInput("output", "--output", "Set the output path"));
convert.Add(new FlagInput("force", "--force", "Overwrite existing files"));
set.Add(convert);

return set;
}

/// <summary>
/// Mock Feature implementation for testing
/// </summary>
Expand Down
80 changes: 80 additions & 0 deletions SabreTools.CommandLine.Test/Features/ManpageTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System;
using System.IO;
using SabreTools.CommandLine.Features;
using SabreTools.CommandLine.Inputs;
using Xunit;

namespace SabreTools.CommandLine.Test.Features
{
public class ManpageTests
{
[Fact]
public void ManpageFeature_WritesPageToStandardOutput()
{
var set = BuildSampleSet();
var info = new ManpageInfo("sample") { Version = "sample 1.0" };
set.Add(new Manpage(info));

var original = Console.Out;
var writer = new StringWriter();
string output;
try
{
Console.SetOut(writer);
bool result = set.ProcessArgs(["man"]);
Assert.True(result);
}
finally
{
Console.SetOut(original);
}

output = writer.ToString();
Assert.Contains(".TH \"SAMPLE\" \"1\"", output);
Assert.Contains(".SH NAME", output);
Assert.Contains(".B \\-\\-convert", output);
}

/// <summary>
/// Build a representative command set with nested inputs and detail text
/// </summary>
private static CommandSet BuildSampleSet()
{
var set = new CommandSet("Sample header line");

var help = new MockFeature("Help", ["?", "h", "help"], "Show this help",
"Built-in to most of the programs is a basic help text.");
set.Add(help);

var convert = new MockFeature("Convert", "--convert", "Convert input files",
"Converts the provided input files into the desired output format.");
convert.Add(new StringInput("output", "--output", "Set the output path"));
convert.Add(new FlagInput("force", "--force", "Overwrite existing files"));
set.Add(convert);

return set;
}

/// <summary>
/// Mock Feature implementation for testing
/// </summary>
private class MockFeature : Feature
{
public MockFeature(string name, string flag, string description, string? detailed = null)
: base(name, flag, description, detailed)
{
}

public MockFeature(string name, string[] flags, string description, string? detailed = null)
: base(name, flags, description, detailed)
{
}

/// <inheritdoc/>
public override bool Execute() => true;

/// <inheritdoc/>
public override bool VerifyInputs() => true;
}
}
}
119 changes: 119 additions & 0 deletions SabreTools.CommandLine/CommandSet.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using SabreTools.CommandLine.Features;
using SabreTools.CommandLine.Inputs;

Expand Down Expand Up @@ -963,6 +964,119 @@ private static void WriteOutWithPauses(List<string> helpText)

#endregion

#region Manpage Output

/// <summary>
/// Generate a man page from the current set of inputs
/// </summary>
/// <param name="info">Document-level metadata for the man page</param>
/// <param name="includeVerbose">True if detailed descriptions should be included, false otherwise</param>
/// <returns>The complete man page as a roff-formatted string</returns>
/// <remarks>
/// The output uses only the portable man(7) macro set so that it
/// renders without warnings under both groff and mandoc. The page
/// content is derived from the same model used to format help text,
/// so it stays in sync with the output of the help features.
/// </remarks>
public string OutputManpage(ManpageInfo info, bool includeVerbose = false)
{
// Start building the output list
List<string> output = [];

// Leading comment and title line
output.Add(".\\\" Generated from the command-line model. Do not edit by hand.");
output.Add(BuildTitleHeader(info));

// Name section
output.Add(".SH NAME");
string nameLine = Roff.Escape(info.Name);
if (!string.IsNullOrEmpty(info.Description))
nameLine += " \\- " + Roff.Escape(info.Description);
output.Add(nameLine);

// Synopsis section
output.Add(".SH SYNOPSIS");
output.Add(".B " + Roff.Escape(info.Name));
output.Add("[options]");

// Description section, derived from the header lines
if (_header.Count > 0)
{
output.Add(".SH DESCRIPTION");
foreach (string line in _header)
{
output.AddRange(Roff.FormatText(line));
}
}

// Options section, derived from all available inputs
output.Add(".SH " + info.OptionsHeading);
foreach (var input in _inputs.Values)
{
output.AddRange(input.FormatManpage(includeVerbose));
}

// If there is a default feature, include its children directly
if (DefaultFeature is not null)
{
foreach (var input in DefaultFeature.Children.Values)
{
output.AddRange(input.FormatManpage(includeVerbose));
}
}

// Notes section, derived from the footer lines
if (_footer.Count > 0)
{
output.Add(".SH NOTES");
foreach (string line in _footer)
{
output.AddRange(Roff.FormatText(line));
}
}

// Join the lines with a trailing newline for a well-formed file
return string.Join("\n", output.ToArray()) + "\n";
}

/// <summary>
/// Generate a man page from the current set of inputs and write it to a file
/// </summary>
/// <param name="path">Path to write the generated man page to</param>
/// <param name="info">Document-level metadata for the man page</param>
/// <param name="includeVerbose">True if detailed descriptions should be included, false otherwise</param>
/// <remarks>The file is written as UTF-8 without a byte order mark</remarks>
public void OutputManpage(string path, ManpageInfo info, bool includeVerbose = false)
{
string content = OutputManpage(info, includeVerbose);
File.WriteAllText(path, content);
}

/// <summary>
/// Build the roff <c>.TH</c> title line from the page metadata
/// </summary>
/// <param name="info">Document-level metadata for the man page</param>
/// <returns>The formatted title line</returns>
private static string BuildTitleHeader(ManpageInfo info)
{
return ".TH "
+ Quote(info.Name.ToUpperInvariant())
+ " " + Quote(info.Section)
+ " " + Quote(info.Date)
+ " " + Quote(info.Version)
+ " " + Quote(info.Title);
}

/// <summary>
/// Wrap an escaped value in double quotes for a <c>.TH</c> field
/// </summary>
/// <param name="value">Value to quote, if any</param>
/// <returns>The escaped, quoted value</returns>
private static string Quote(string? value)
=> "\"" + Roff.EscapeField(value) + "\"";

#endregion

#region Processing

/// <summary>
Expand Down Expand Up @@ -1008,6 +1122,11 @@ public bool ProcessArgs(string[] args)
helpExtFeature.ProcessArgs(args, 0, this);
return true;
}
else if (topLevel is Manpage manFeature)
{
manFeature.ProcessArgs(args, 0, this);
return true;
}

// Now verify that all other flags are valid
if (!feature.ProcessArgs(args, 1))
Expand Down
Loading