Skip to content

Commit ae311fc

Browse files
ScottArbeitScott Arbeit
andauthored
PR: Grace CLI Feature Specification: grace history (User-Level Command History) (#39)
* Rebase branch updates and local history work * fixes for first code review comment about configuraiton * Resolved code review comment. * Small cleanup. --------- Co-authored-by: Scott Arbeit <scottarbeit@github.com>
1 parent 7a4d85f commit ae311fc

21 files changed

+1836
-89
lines changed

src/Grace.CLI.Tests/AGENTS.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Grace.CLI.Tests Agents Guide
2+
3+
Read `../AGENTS.md` for global expectations before updating CLI tests.
4+
5+
## Purpose
6+
- Validate CLI-specific helpers and behaviors without requiring server access.
7+
- Prefer unit tests that exercise parsing, redaction, and history storage logic directly.
8+
9+
## Key Patterns
10+
1. Keep tests isolated: back up and restore any files touched under `~/.grace`.
11+
2. Use FsUnit for assertions and FsCheck for property-based coverage when appropriate.
12+
3. Prefer deterministic timestamps for history-related tests.
13+
14+
## Validation
15+
- Run `dotnet test --no-build` after changes.
16+
- If build issues appear, run `dotnet build --configuration Release` first.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<TargetFramework>net10.0</TargetFramework>
4+
<LangVersion>preview</LangVersion>
5+
<IsPackable>false</IsPackable>
6+
<GenerateProgramFile>false</GenerateProgramFile>
7+
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
8+
<SuppressNETCoreSdkPreviewMessage>true</SuppressNETCoreSdkPreviewMessage>
9+
<OtherFlags>--test:GraphBasedChecking</OtherFlags>
10+
<OtherFlags>--test:ParallelOptimization</OtherFlags>
11+
<OtherFlags>--test:ParallelIlxGen</OtherFlags>
12+
</PropertyGroup>
13+
14+
<ItemGroup>
15+
<Compile Include="HistoryStorage.Tests.fs" />
16+
<Compile Include="Program.fs" />
17+
</ItemGroup>
18+
19+
<ItemGroup>
20+
<PackageReference Include="FsCheck" Version="3.2.0" />
21+
<PackageReference Include="FsCheck.NUnit" Version="3.2.0" />
22+
<PackageReference Include="FsUnit" Version="7.1.1" />
23+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
24+
<PackageReference Include="NUnit" Version="4.4.0" />
25+
<PackageReference Include="NUnit3TestAdapter" Version="5.2.0" />
26+
<PackageReference Include="NUnit.Analyzers" Version="4.11.2">
27+
<PrivateAssets>all</PrivateAssets>
28+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
29+
</PackageReference>
30+
<PackageReference Include="coverlet.collector" Version="6.0.4">
31+
<PrivateAssets>all</PrivateAssets>
32+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
33+
</PackageReference>
34+
</ItemGroup>
35+
36+
<ItemGroup>
37+
<ProjectReference Include="..\Grace.CLI\Grace.CLI.fsproj" />
38+
<ProjectReference Include="..\Grace.Shared\Grace.Shared.fsproj" />
39+
</ItemGroup>
40+
41+
<ItemGroup>
42+
<PackageReference Update="FSharp.Core" Version="10.0.100" />
43+
</ItemGroup>
44+
</Project>
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
namespace Grace.CLI.Tests
2+
3+
open FsUnit
4+
open Grace.CLI
5+
open Grace.Shared
6+
open Grace.Shared.Client
7+
open Grace.Shared.Utilities
8+
open NodaTime
9+
open NUnit.Framework
10+
open System
11+
open System.IO
12+
open System.Text.Json
13+
14+
[<NonParallelizable>]
15+
module HistoryStorageTests =
16+
17+
let private withFileBackup (path: string) (action: unit -> unit) =
18+
let backupPath = path + ".testbackup"
19+
let hadExisting = File.Exists(path)
20+
21+
if hadExisting then File.Copy(path, backupPath, true)
22+
23+
try
24+
action ()
25+
finally
26+
if hadExisting then
27+
File.Copy(backupPath, path, true)
28+
File.Delete(backupPath)
29+
elif File.Exists(path) then
30+
File.Delete(path)
31+
32+
let private randomString (random: Random) =
33+
let length = random.Next(8, 32)
34+
let chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
35+
let buffer = Array.zeroCreate<char> length
36+
37+
for i in 0 .. length - 1 do
38+
buffer[i] <- chars[random.Next(chars.Length)]
39+
40+
String(buffer)
41+
42+
[<Test>]
43+
let ``redacts --token values`` () =
44+
let config = UserConfiguration.UserConfiguration()
45+
let random = Random(1337)
46+
47+
for _ in 0..49 do
48+
let value = randomString random
49+
let args = [| "--token"; value |]
50+
let redacted, _ = HistoryStorage.redactArguments args config.History
51+
redacted |> Array.exists (fun arg -> arg = value) |> should equal false
52+
53+
[<Test>]
54+
let ``redacts --token=value values`` () =
55+
let config = UserConfiguration.UserConfiguration()
56+
let random = Random(7331)
57+
58+
for _ in 0..49 do
59+
let value = randomString random
60+
let args = [| $"--token={value}" |]
61+
let redacted, _ = HistoryStorage.redactArguments args config.History
62+
63+
redacted
64+
|> Array.exists (fun arg -> arg.Contains(value, StringComparison.Ordinal))
65+
|> should equal false
66+
67+
[<TestCase("30s", 30.0)>]
68+
[<TestCase("10m", 600.0)>]
69+
[<TestCase("24h", 86400.0)>]
70+
[<TestCase("7d", 604800.0)>]
71+
let ``parses valid durations`` (input: string, expectedSeconds: float) =
72+
match HistoryStorage.tryParseDuration input with
73+
| Ok duration -> duration.TotalSeconds |> should equal expectedSeconds
74+
| Error error -> Assert.Fail($"Expected Ok, got Error: {error}")
75+
76+
[<TestCase("")>]
77+
[<TestCase("10x")>]
78+
[<TestCase("abc")>]
79+
[<TestCase("10")>]
80+
let ``rejects invalid durations`` (input: string) =
81+
match HistoryStorage.tryParseDuration input with
82+
| Ok _ -> Assert.Fail("Expected Error, got Ok.")
83+
| Error _ -> Assert.Pass()
84+
85+
[<Test>]
86+
let ``readHistoryEntries skips corrupt lines`` () =
87+
let historyPath = HistoryStorage.getHistoryFilePath ()
88+
let historyDir = Path.GetDirectoryName(historyPath)
89+
Directory.CreateDirectory(historyDir) |> ignore
90+
91+
let options = JsonSerializerOptions(Constants.JsonSerializerOptions)
92+
options.WriteIndented <- false
93+
94+
let entry: HistoryStorage.HistoryEntry =
95+
{ id = Guid.NewGuid()
96+
timestampUtc = getCurrentInstant ()
97+
argvOriginal = [| "branch"; "status" |]
98+
argvNormalized = [| "branch"; "status" |]
99+
commandLine = "branch status"
100+
cwd = Environment.CurrentDirectory
101+
repoRoot = None
102+
repoName = None
103+
repoBranch = None
104+
graceVersion = "0.1"
105+
exitCode = 0
106+
durationMs = 10L
107+
parseSucceeded = true
108+
redactions = List.empty
109+
source = None }
110+
111+
let json = JsonSerializer.Serialize(entry, options)
112+
113+
withFileBackup historyPath (fun () ->
114+
File.WriteAllLines(historyPath, [| json; "not json" |])
115+
let result = HistoryStorage.readHistoryEntries ()
116+
result.Entries.Length |> should equal 1
117+
result.CorruptCount |> should equal 1)
118+
119+
[<Test>]
120+
let ``prunes history to max entries`` () =
121+
let historyPath = HistoryStorage.getHistoryFilePath ()
122+
let configPath = UserConfiguration.getUserConfigurationPath ()
123+
let historyDir = Path.GetDirectoryName(historyPath)
124+
Directory.CreateDirectory(historyDir) |> ignore
125+
126+
withFileBackup configPath (fun () ->
127+
withFileBackup historyPath (fun () ->
128+
let configuration = UserConfiguration.UserConfiguration()
129+
configuration.History.Enabled <- true
130+
configuration.History.MaxEntries <- 2
131+
configuration.History.MaxFileBytes <- 1024L * 1024L
132+
configuration.History.RetentionDays <- 365
133+
134+
match UserConfiguration.saveUserConfiguration configuration with
135+
| Ok _ -> ()
136+
| Error error -> Assert.Fail(error)
137+
138+
File.WriteAllText(historyPath, String.Empty)
139+
140+
let start = getCurrentInstant ()
141+
142+
for offset in [ 0..2 ] do
143+
let timestamp = start.Plus(Duration.FromMinutes(float offset))
144+
145+
HistoryStorage.tryRecordInvocation
146+
{ argvOriginal = [| "branch"; "status" |]
147+
argvNormalized = [| "branch"; "status" |]
148+
cwd = Environment.CurrentDirectory
149+
exitCode = 0
150+
durationMs = 5L
151+
parseSucceeded = true
152+
timestampUtc = timestamp
153+
source = None }
154+
155+
let result = HistoryStorage.readHistoryEntries ()
156+
result.Entries.Length |> should equal 2))

src/Grace.CLI.Tests/Program.fs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
namespace Grace.CLI.Tests
2+
3+
module Program =
4+
[<EntryPoint>]
5+
let main _ = 0

src/Grace.CLI/AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ Read `../AGENTS.md` for global expectations before updating CLI code.
1717
2. Preserve existing option names/switches; introduce new aliases when expanding behavior instead of breaking existing scripts.
1818
3. Capture new command patterns or usage tips in this document to guide future agents.
1919

20+
## Recent Patterns
21+
- `grace history` commands operate without requiring a repo `graceconfig.json`; avoid `Configuration.Current()` in history-related flows.
22+
2023
## Validation
2124
- Add option parsing tests and handler unit tests for new functionality.
2225
- Manually exercise impacted commands when practical and ensure `dotnet build --configuration Release` stays green.

src/Grace.CLI/Command/Branch.CLI.fs

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -700,7 +700,7 @@ module Branch =
700700
type ListContents() =
701701
inherit AsynchronousCommandLineAction()
702702

703-
override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) : Task<int> =
703+
override _.InvokeAsync(parseResult: ParseResult, cancellationToken: CancellationToken) =
704704
task {
705705
try
706706
if parseResult |> verbose then printParseResult parseResult
@@ -712,18 +712,18 @@ module Branch =
712712
match validateIncomingParameters with
713713
| Ok _ ->
714714
let referenceId =
715-
if isNull (parseResult.GetResult(Options.referenceId)) then
715+
if isNull (parseResult.GetResult Options.referenceId) then
716716
String.Empty
717717
else
718-
parseResult.GetValue(Options.referenceId).ToString()
718+
(parseResult.GetValue Options.referenceId).ToString()
719719

720720
let sha256Hash =
721-
if isNull (parseResult.GetResult(Options.sha256Hash)) then
721+
if isNull (parseResult.GetResult Options.sha256Hash) then
722722
String.Empty
723723
else
724-
parseResult.GetValue(Options.sha256Hash)
724+
parseResult.GetValue Options.sha256Hash
725725

726-
let forceRecompute = parseResult.GetValue(Options.forceRecompute)
726+
let forceRecompute = parseResult.GetValue Options.forceRecompute
727727

728728
let sdkParameters =
729729
ListContentsParameters(
@@ -751,18 +751,15 @@ module Branch =
751751
.StartAsync(fun progressContext ->
752752
task {
753753
let t0 = progressContext.AddTask($"[{Color.DodgerBlue1}]Sending command to the server.[/]")
754-
755754
let! response = Branch.ListContents(sdkParameters)
756-
t0.Increment(100.0)
755+
t0.Value <- 100.0
757756
return response
758757
})
759758
else
760759
Branch.ListContents(sdkParameters)
761760

762761
match result with
763762
| Ok returnValue ->
764-
let! _ = readGraceStatusFile ()
765-
766763
let directoryVersions =
767764
returnValue.ReturnValue
768765
.Select(fun directoryVersionDto -> directoryVersionDto.DirectoryVersion)

src/Grace.CLI/Command/Connect.CLI.fs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -232,11 +232,6 @@ module Connect =
232232

233233
// Download the .zip file to temp directory.
234234
let blobClient = BlobClient(uriWithSharedAccessSignature)
235-
//let zipFilePath = Path.Combine(Current().GraceDirectory, $"{branchDto.LatestPromotion.DirectoryId}.zip")
236-
//let! downloadResponse = blobClient.DownloadToAsync(zipFilePath)
237-
238-
//if downloadResponse.Status = 200 then
239-
// AnsiConsole.MarkupLine $"[{Colors.Important}]Successfully downloaded zip file to {zipFilePath}.[/]"
240235

241236
// Loop through the ZipArchiveEntry list, identify if each file version is binary, and extract
242237
// each one accordingly.

0 commit comments

Comments
 (0)