canontra-0.1.0.0: test/Canontra/CLISpec.hs
{-# LANGUAGE OverloadedStrings #-}
{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-}
{- |
Module : Canontra.CLISpec
Description : Test suite for Phase 4 Production CLI Ecosystem & Tooling.
Verifies:
1. Native shell completion generators for Bash, Zsh, Fish, and PowerShell.
2. Cache maintenance commands (info, verify, clean, prune) on CNTR\x05 binary caches.
3. Export subcommands generating OASIS SARIF v2.1.0 and Graphviz DOT formats.
4. Deterministic stdin language resolution and synthetic path mapping.
5. Strict POSIX exit code contracts (0 Identical, 1 Changed, 3 Parse Error, 4 IO/Security).
-}
module Canontra.CLISpec (spec) where
import qualified Data.Map.Strict as Map
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import System.Directory
( createDirectoryIfMissing
, doesFileExist
, getTemporaryDirectory
, removeDirectoryRecursive
)
import System.FilePath ((</>))
import Test.Hspec
import Canontra.Analysis.CallGraph (buildCallGraph)
import Canontra.Analysis.CFG (buildCFGs)
import Canontra.Analysis.DFG (buildDFGs)
import Canontra.Cache.Common (MerkleCache (..), MerkleCacheEntry (..))
import Canontra.Cache.PagedCache (readPagedCacheFileResilient, writePagedCacheFile)
import Canontra.CLI.Cache (CacheAction (..), runCacheCommand)
import Canontra.CLI.Completions
( ShellType (..)
, generateCompletionScript
, parseShellType
)
import Canontra.Comparison.Diff (diffPrograms)
import Canontra.Export.Graph (exportCallGraphDOT, exportCFGDOT, exportDFGDOT)
import Canontra.Export.SARIF (exportDiffSARIF, renderSARIF)
import Canontra.Fingerprint.Bundle (computeBundle, computeManifest)
import Canontra.Parser.Polyglot (parsePolyglotSource)
import Canontra.Types (Fingerprint (..), FingerprintBundle (..), Manifest (..))
spec :: Spec
spec = do
describe "Canontra.CLI (Phase 4 Production CLI Ecosystem)" $ do
-- ========================================================================
-- 1. Shell Autocompletion Generators
-- ========================================================================
describe "Shell Autocompletions (Canontra.CLI.Completions)" $ do
it "parses supported shell names case-insensitively" $ do
parseShellType "bash" `shouldBe` Just ShellBash
parseShellType "BASH" `shouldBe` Just ShellBash
parseShellType "zsh" `shouldBe` Just ShellZsh
parseShellType "fish" `shouldBe` Just ShellFish
parseShellType "powershell" `shouldBe` Just ShellPowerShell
parseShellType "pwsh" `shouldBe` Just ShellPowerShell
parseShellType "unknown" `shouldBe` Nothing
it "generates valid Bash completion script with command table" $ do
let script = T.unpack $ generateCompletionScript ShellBash
script `shouldContain` "complete -F _canontra canontra"
script `shouldContain` "fp fingerprint compare diff graph"
script `shouldContain` "cache export completions"
script `shouldContain` "_filedir"
it "generates valid Zsh completion script with compdef" $ do
let script = T.unpack $ generateCompletionScript ShellZsh
script `shouldContain` "#compdef canontra"
script `shouldContain` "'fp:Compute deterministic multi-tier fingerprints'"
script `shouldContain` "'cache:Inspect, verify, clean, or prune incremental binary cache'"
script `shouldContain` "'export:Export diagnostics (SARIF) or graphs (DOT)'"
it "generates valid Fish completion script with completions table" $ do
let script = T.unpack $ generateCompletionScript ShellFish
script `shouldContain` "complete -c canontra"
script `shouldContain` "__fish_use_subcommand"
script `shouldContain` "-a fp"
script `shouldContain` "-a cache"
script `shouldContain` "-a export"
it "generates valid PowerShell completion script with ArgumentCompleter" $ do
let script = T.unpack $ generateCompletionScript ShellPowerShell
script `shouldContain` "Register-ArgumentCompleter -Native -CommandName canontra"
script `shouldContain` "[System.Management.Automation.CompletionResult]::new('fp'"
script `shouldContain` "[System.Management.Automation.CompletionResult]::new('cache'"
script `shouldContain` "[System.Management.Automation.CompletionResult]::new('export'"
-- ========================================================================
-- 2. Cache Tooling (Canontra.CLI.Cache)
-- ========================================================================
describe "Cache Maintenance Tooling (Canontra.CLI.Cache)" $ do
it "handles CacheInfo cleanly when no cache exists" $ do
tempBase <- getTemporaryDirectory
let tempDir = tempBase </> "canontra_test_nocache"
createDirectoryIfMissing True tempDir
runCacheCommand CacheInfo tempDir False
runCacheCommand CacheClean tempDir False
removeDirectoryRecursive tempDir
it "verifies and reports clean CRC32 on an initialized cache" $ do
tempBase <- getTemporaryDirectory
let tempDir = tempBase </> "canontra_test_cache"
cacheDir = tempDir </> ".canontra"
cacheFile = cacheDir </> "cache.bin"
createDirectoryIfMissing True cacheDir
-- Create sample cache entry
let dummyFp = Fingerprint "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
dummyBundle = FingerprintBundle dummyFp dummyFp dummyFp dummyFp dummyFp dummyFp dummyFp dummyFp dummyFp
entry = MerkleCacheEntry 100 1234567 dummyBundle
cache = MerkleCache (Map.singleton "main.py" entry)
writePagedCacheFile cacheFile cache
exists <- doesFileExist cacheFile
exists `shouldBe` True
-- Run CacheInfo and CacheVerify
runCacheCommand CacheInfo tempDir False
runCacheCommand CacheVerify tempDir False
-- Clean cache
runCacheCommand CacheClean tempDir False
cleanedExists <- doesFileExist cacheFile
cleanedExists `shouldBe` False
removeDirectoryRecursive tempDir
it "prunes orphaned cache entries when underlying source file is deleted" $ do
tempBase <- getTemporaryDirectory
let tempDir = tempBase </> "canontra_test_prune"
cacheDir = tempDir </> ".canontra"
cacheFile = cacheDir </> "cache.bin"
sourceFile1 = tempDir </> "kept.py"
createDirectoryIfMissing True cacheDir
writeFile sourceFile1 "def kept(): pass\n"
let dummyFp = Fingerprint "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
dummyBundle = FingerprintBundle dummyFp dummyFp dummyFp dummyFp dummyFp dummyFp dummyFp dummyFp dummyFp
entry1 = MerkleCacheEntry 100 1234567 dummyBundle
entry2 = MerkleCacheEntry 100 1234567 dummyBundle
cache = MerkleCache (Map.fromList [("kept.py", entry1), ("deleted.py", entry2)])
writePagedCacheFile cacheFile cache
-- Prune: kept.py exists, deleted.py does not exist
runCacheCommand CachePrune tempDir False
-- Verify pruned cache
updated <- readPagedCacheFileResilient cacheFile
Map.member "kept.py" (unMerkleCache updated) `shouldBe` True
Map.member "deleted.py" (unMerkleCache updated) `shouldBe` False
removeDirectoryRecursive tempDir
-- ========================================================================
-- 3. Stdin Streaming & Synthetic Language Resolution
-- ========================================================================
describe "Standard Input Streaming & Manifest Resolution" $ do
it "computes bit-identical bundle for Python source whether file or stdin" $ do
let src = "def square(x: int) -> int:\n return x * x\n"
raw = TE.encodeUtf8 src
case (computeBundle "math.py" raw src, computeBundle "stdin.py" raw src) of
(Right bFile, Right bStdin) -> do
f1Structural bFile `shouldBe` f1Structural bStdin
f2Declaration bFile `shouldBe` f2Declaration bStdin
f3Dependency bFile `shouldBe` f3Dependency bStdin
fTTypeContract bFile `shouldBe` fTTypeContract bStdin
f4Composite bFile `shouldBe` f4Composite bStdin
_ -> expectationFailure "Bundle computation failed"
it "resolves polyglot languages for stdin stream" $ do
let tsSrc = "export function add(a: number, b: number): number { return a + b; }"
goSrc = "package main\nfunc Add(a int, b int) int { return a + b }\n"
rsSrc = "pub fn add(a: i32, b: i32) -> i32 { a + b }\n"
case ( computeManifest "stdin.ts" (TE.encodeUtf8 tsSrc) tsSrc
, computeManifest "stdin.go" (TE.encodeUtf8 goSrc) goSrc
, computeManifest "stdin.rs" (TE.encodeUtf8 rsSrc) rsSrc
) of
(Right mTS, Right mGo, Right mRs) -> do
mLanguage mTS `shouldBe` "typescript"
mLanguage mGo `shouldBe` "go"
mLanguage mRs `shouldBe` "rust"
_ -> expectationFailure "Polyglot manifest computation failed"
-- ========================================================================
-- 4. Export Command Functionality (SARIF & Graphviz DOT)
-- ========================================================================
describe "Export Command Generators" $ do
it "exports SARIF with standard tool driver rules and diff" $ do
let codeA = "def greeting(name: str) -> str:\n return 'Hello ' + name\n"
codeB = "def greeting(name: str, shout: bool = False) -> str:\n return 'HELLO ' + name\n"
case (parsePolyglotSource "greet.py" codeA, parsePolyglotSource "greet.py" codeB) of
(Right p1, Right p2) -> do
let diffRes = diffPrograms p1 p2
sarif = exportDiffSARIF "greet.py" diffRes
rendered = T.unpack $ renderSARIF sarif
rendered `shouldContain` "\"$schema\""
rendered `shouldContain` "\"version\": \"2.1.0\""
rendered `shouldContain` "CTR001_InterfaceBreak"
_ -> expectationFailure "Parsing failed for SARIF export test"
it "exports CallGraph in DOT format" $ do
case parsePolyglotSource "app.py" "def hello(): pass\n" of
Left err -> expectationFailure (show err)
Right prog -> do
let cg = buildCallGraph prog
dot = T.unpack $ exportCallGraphDOT cg
dot `shouldContain` "digraph CallGraph"
dot `shouldContain` "fn_hello"
it "exports CFG and DFG in DOT format" $ do
let src = "def branch(x):\n if x > 0:\n return 1\n return 0\n"
case parsePolyglotSource "branch.py" src of
Left err -> expectationFailure (show err)
Right prog -> do
let cfgs = buildCFGs prog
dfgs = buildDFGs prog
cfgDot = T.unpack $ exportCFGDOT cfgs
dfgDot = T.unpack $ exportDFGDOT dfgs
cfgDot `shouldContain` "digraph ControlFlowGraph"
dfgDot `shouldContain` "digraph DataFlowGraph"