diff --git a/Tests/CLI/CLIAnnotationStateTests.swift b/Tests/CLI/CLIAnnotationStateTests.swift new file mode 100644 index 00000000..2ab937ca --- /dev/null +++ b/Tests/CLI/CLIAnnotationStateTests.swift @@ -0,0 +1,103 @@ +import Foundation +import Testing +import VitrineDomain +import VitrineRendering + +@testable import VitrineCLICore + +@MainActor +@Suite("CLI annotation state compatibility") +struct CLIAnnotationStateTests { + @Test func interleavedMarksUseFinalPerKindStyleAndKeepWithinKindOrder() throws { + let options = try CLIArguments.parse([ + "render", "input.swift", "-o", "output.png", + "--arrow-color", "#FF0000", "--arrow", "0.1,0.2,0.3,0.4", + "--line", "0.2,0.6,0.8,0.6", "--arrow-color", "#0000FF", + "--arrow", "0.5,0.6,0.7,0.8", "--arrow-size", "3", "--arrow-size", "9", + "--line-color", "#00FF00", "--line-size", "5", + "--highlighter-color", "#FFFFFF", "--highlighter", "0.1,0.1,0.2,0.2", + "--blur-box", "0.2,0.2,0.3,0.3", "--rectangle", "0.3,0.3,0.4,0.4", + ]) + #expect(options.arrows.map(\.start) == [CGPoint(x: 0.1, y: 0.2), CGPoint(x: 0.5, y: 0.6)]) + #expect(options.arrows.allSatisfy { $0.color == RGBAColor(hex: "#0000FF") && $0.size == 9 }) + #expect(options.lines.first?.color == RGBAColor(hex: "#00FF00")) + #expect(options.lines.first?.size == 5) + #expect(options.highlighters.first?.size == nil) + #expect(options.blurBoxes.first?.color == nil) + #expect(options.blurBoxes.first?.size == nil) + #expect( + options.makeConfig(code: "x", language: .swift).annotations.map(\.kind) + == [.arrow, .arrow, .line, .rectangle, .highlighter, .blur]) + } + + @Test func repeatedPointValuesKeepLastAssignmentAndCanonicalModelOrder() throws { + let options = try CLIArguments.parse([ + "render", "input.swift", "-o", "output.png", "--counter", "2", + "--callout-y", "0.4", "--callout", "Earlier", "--counter-y", "0.8", + "--callout-x", "0.3", "--counter-x", "0.7", "--counter", "8", + "--callout", "Final", "--callout-x", "0.2", + ]) + #expect(options.calloutText == "Final") + #expect(options.calloutPosition == CGPoint(x: 0.2, y: 0.4)) + #expect(options.counterNumber == 8) + #expect(options.counterPosition == CGPoint(x: 0.7, y: 0.8)) + #expect( + options.makeConfig(code: "x", language: .swift).annotations.map(\.kind) + == [.text, .counter]) + } + + @Test func semanticErrorsKeepKindPrecedenceRegardlessOfFlagOrder() { + for modifiers in [ + ["--line-size", "5", "--arrow-size", "7"], + ["--arrow-size", "7", "--line-size", "5"], + ] { + #expect( + throws: CLIError.incompatibleOptions( + "--arrow-color and --arrow-size require --arrow.") + ) { + try CLIArguments.parse(["render", "input.swift", "-o", "output.png"] + modifiers) + } + } + #expect( + throws: CLIError.incompatibleOptions( + "--callout-x and --callout-y must be provided together.") + ) { + try CLIArguments.parse([ + "render", "input.swift", "-o", "output.png", "--counter-size", "5", + "--callout", "Note", "--callout-x", "0.2", + ]) + } + } + + @Test func syntaxAndCrossFeatureErrorsKeepTheirExistingPrecedence() { + #expect(throws: CLIError.invalidValue(flag: "--line", value: "invalid")) { + try CLIArguments.parse([ + "render", "input.swift", "-o", "output.png", "--arrow-size", "7", + "--line", "invalid", + ]) + } + #expect( + throws: CLIError.incompatibleOptions("--arrow-color and --arrow-size require --arrow.") + ) { + try CLIArguments.parse([ + "render", "input.swift", "-o", "output.png", "--frame", "browser", + "--arrow-size", "7", + ]) + } + } + + @Test func annotationStateDoesNotSurviveAnotherInvocation() throws { + let styled = try CLIArguments.parse([ + "render", "input.swift", "-o", "output.png", "--arrow", "0.1,0.1,0.9,0.9", + "--arrow-color", "#0000FF", "--arrow-size", "9", + ]) + let fresh = try CLIArguments.parse([ + "render", "input.swift", "-o", "output.png", "--arrow", "0.1,0.1,0.9,0.9", + ]) + #expect(styled.arrows.first?.size == 9) + #expect(fresh.arrows.first?.color == nil) + #expect(fresh.arrows.first?.size == nil) + #expect(fresh.calloutText == nil) + #expect(fresh.counterNumber == nil) + } +} diff --git a/Vitrine/CLI/CLIAnnotationArguments.swift b/Vitrine/CLI/CLIAnnotationArguments.swift new file mode 100644 index 00000000..452dad95 --- /dev/null +++ b/Vitrine/CLI/CLIAnnotationArguments.swift @@ -0,0 +1,85 @@ +import Foundation +import VitrineDomain + +/// Invocation-local annotation values. Token parsing remains in `CLIArgumentParser`; +/// this value owns modifier compatibility and projection into the stable CLI options. +struct CLIAnnotationArguments { + struct PositionedStyle { + var x: Double? + var y: Double? + var color: RGBAColor? + var size: Double? + + var isRequested: Bool { x != nil || y != nil || color != nil || size != nil } + var hasPartialPosition: Bool { (x == nil) != (y == nil) } + var position: CGPoint? { + guard let x, let y else { return nil } + return CGPoint(x: x, y: y) + } + } + + struct GeometryGroup { + var points: [(start: CGPoint, end: CGPoint)] = [] + var color: RGBAColor? + var size: Double? + + var hasStyle: Bool { color != nil || size != nil } + var materialized: [CLIOptions.SegmentAnnotation] { + points.map { + CLIOptions.SegmentAnnotation(start: $0.start, end: $0.end, color: color, size: size) + } + } + } + + var calloutText: String? + var callout = PositionedStyle() + var counterNumber: Int? + var counter = PositionedStyle() + var arrows = GeometryGroup() + var lines = GeometryGroup() + var rectangles = GeometryGroup() + var highlighters = GeometryGroup() + var blurBoxes = GeometryGroup() + + /// The compatibility pass has already rejected orphan modifiers before checking + /// `--edit`, so only actual annotation content participates in its style decision. + var hasContent: Bool { + calloutText != nil || counterNumber != nil || !arrows.points.isEmpty + || !lines.points.isEmpty || !rectangles.points.isEmpty + || !highlighters.points.isEmpty || !blurBoxes.points.isEmpty + } + + /// Keep the established kind order and exact diagnostics, even when argv contains + /// several incompatible annotation groups in a different order. + func validate() throws { + if calloutText == nil, callout.isRequested { + throw CLIError.incompatibleOptions( + "--callout-x, --callout-y, --callout-color, and --callout-size require --callout.") + } + if callout.hasPartialPosition { + throw CLIError.incompatibleOptions( + "--callout-x and --callout-y must be provided together.") + } + if counterNumber == nil, counter.isRequested { + throw CLIError.incompatibleOptions( + "--counter-x, --counter-y, --counter-color, and --counter-size require --counter.") + } + if counter.hasPartialPosition { + throw CLIError.incompatibleOptions( + "--counter-x and --counter-y must be provided together.") + } + if arrows.points.isEmpty, arrows.hasStyle { + throw CLIError.incompatibleOptions("--arrow-color and --arrow-size require --arrow.") + } + if lines.points.isEmpty, lines.hasStyle { + throw CLIError.incompatibleOptions("--line-color and --line-size require --line.") + } + if rectangles.points.isEmpty, rectangles.hasStyle { + throw CLIError.incompatibleOptions( + "--rectangle-color and --rectangle-size require --rectangle.") + } + if highlighters.points.isEmpty, highlighters.color != nil { + throw CLIError.incompatibleOptions("--highlighter-color requires --highlighter.") + } + } +} diff --git a/Vitrine/CLI/CLIArgumentParser.swift b/Vitrine/CLI/CLIArgumentParser.swift index f9e00c8e..ced37fae 100644 --- a/Vitrine/CLI/CLIArgumentParser.swift +++ b/Vitrine/CLI/CLIArgumentParser.swift @@ -47,28 +47,7 @@ struct CLIArgumentParser { var watermarkPosition: CLIOptions.WatermarkPosition? var watermarkX: Double? var watermarkY: Double? - var calloutText: String? - var calloutX: Double? - var calloutY: Double? - var calloutColor: RGBAColor? - var calloutSize: Double? - var counterNumber: Int? - var counterX: Double? - var counterY: Double? - var counterColor: RGBAColor? - var counterSize: Double? - var arrowSegments: [(start: CGPoint, end: CGPoint)] = [] - var arrowColor: RGBAColor? - var arrowSize: Double? - var lineSegments: [(start: CGPoint, end: CGPoint)] = [] - var lineColor: RGBAColor? - var lineSize: Double? - var rectangleRegions: [(start: CGPoint, end: CGPoint)] = [] - var rectangleColor: RGBAColor? - var rectangleSize: Double? - var highlighterRegions: [(start: CGPoint, end: CGPoint)] = [] - var highlighterColor: RGBAColor? - var blurBoxRegions: [(start: CGPoint, end: CGPoint)] = [] + var annotations = CLIAnnotationArguments() var imageFrame: CLIOptions.ImageFrameOption? var frameAppearance: CLIOptions.ImageFrameAppearance? var noOverwrite = false @@ -258,49 +237,49 @@ struct CLIArgumentParser { case .watermarkY: watermarkY = try resolveNormalizedCoordinate(value, flag: token) case .callout: - calloutText = try resolveCalloutText(value) + annotations.calloutText = try resolveCalloutText(value) case .calloutX: - calloutX = try resolveNormalizedCoordinate(value, flag: token) + annotations.callout.x = try resolveNormalizedCoordinate(value, flag: token) case .calloutY: - calloutY = try resolveNormalizedCoordinate(value, flag: token) + annotations.callout.y = try resolveNormalizedCoordinate(value, flag: token) case .calloutColor: - calloutColor = try resolveCalloutColor(value) + annotations.callout.color = try resolveCalloutColor(value) case .calloutSize: - calloutSize = try resolveCalloutSize(value) + annotations.callout.size = try resolveCalloutSize(value) case .counter: - counterNumber = try resolveCounterNumber(value) + annotations.counterNumber = try resolveCounterNumber(value) case .counterX: - counterX = try resolveNormalizedCoordinate(value, flag: token) + annotations.counter.x = try resolveNormalizedCoordinate(value, flag: token) case .counterY: - counterY = try resolveNormalizedCoordinate(value, flag: token) + annotations.counter.y = try resolveNormalizedCoordinate(value, flag: token) case .counterColor: - counterColor = try resolveHexColor(value, flag: token) + annotations.counter.color = try resolveHexColor(value, flag: token) case .counterSize: - counterSize = try resolveAnnotationSize(value, flag: token) + annotations.counter.size = try resolveAnnotationSize(value, flag: token) case .arrow: - arrowSegments.append(try resolveNormalizedSegment(value, flag: token)) + annotations.arrows.points.append(try resolveNormalizedSegment(value, flag: token)) case .arrowColor: - arrowColor = try resolveHexColor(value, flag: token) + annotations.arrows.color = try resolveHexColor(value, flag: token) case .arrowSize: - arrowSize = try resolveAnnotationSize(value, flag: token) + annotations.arrows.size = try resolveAnnotationSize(value, flag: token) case .line: - lineSegments.append(try resolveNormalizedSegment(value, flag: token)) + annotations.lines.points.append(try resolveNormalizedSegment(value, flag: token)) case .lineColor: - lineColor = try resolveHexColor(value, flag: token) + annotations.lines.color = try resolveHexColor(value, flag: token) case .lineSize: - lineSize = try resolveAnnotationSize(value, flag: token) + annotations.lines.size = try resolveAnnotationSize(value, flag: token) case .rectangle: - rectangleRegions.append(try resolveNormalizedRegion(value, flag: token)) + annotations.rectangles.points.append(try resolveNormalizedRegion(value, flag: token)) case .rectangleColor: - rectangleColor = try resolveHexColor(value, flag: token) + annotations.rectangles.color = try resolveHexColor(value, flag: token) case .rectangleSize: - rectangleSize = try resolveAnnotationSize(value, flag: token) + annotations.rectangles.size = try resolveAnnotationSize(value, flag: token) case .highlighter: - highlighterRegions.append(try resolveNormalizedRegion(value, flag: token)) + annotations.highlighters.points.append(try resolveNormalizedRegion(value, flag: token)) case .highlighterColor: - highlighterColor = try resolveHexColor(value, flag: token) + annotations.highlighters.color = try resolveHexColor(value, flag: token) case .blurBox: - blurBoxRegions.append(try resolveNormalizedRegion(value, flag: token)) + annotations.blurBoxes.points.append(try resolveNormalizedRegion(value, flag: token)) case .frame: imageFrame = try resolveImageFrame(value) case .frameAppearance: diff --git a/Vitrine/CLI/CLIArgumentValidation.swift b/Vitrine/CLI/CLIArgumentValidation.swift index c874c79f..6e757be5 100644 --- a/Vitrine/CLI/CLIArgumentValidation.swift +++ b/Vitrine/CLI/CLIArgumentValidation.swift @@ -168,41 +168,7 @@ extension CLIArgumentParser { throw CLIError.incompatibleOptions( "--watermark-x and --watermark-y require --watermark-position free.") } - if calloutText == nil, - calloutX != nil || calloutY != nil || calloutColor != nil || calloutSize != nil - { - throw CLIError.incompatibleOptions( - "--callout-x, --callout-y, --callout-color, and --callout-size require --callout.") - } - if (calloutX == nil) != (calloutY == nil) { - throw CLIError.incompatibleOptions( - "--callout-x and --callout-y must be provided together.") - } - if counterNumber == nil, - counterX != nil || counterY != nil || counterColor != nil || counterSize != nil - { - throw CLIError.incompatibleOptions( - "--counter-x, --counter-y, --counter-color, and --counter-size require --counter.") - } - if (counterX == nil) != (counterY == nil) { - throw CLIError.incompatibleOptions( - "--counter-x and --counter-y must be provided together.") - } - if arrowSegments.isEmpty, arrowColor != nil || arrowSize != nil { - throw CLIError.incompatibleOptions( - "--arrow-color and --arrow-size require --arrow.") - } - if lineSegments.isEmpty, lineColor != nil || lineSize != nil { - throw CLIError.incompatibleOptions( - "--line-color and --line-size require --line.") - } - if rectangleRegions.isEmpty, rectangleColor != nil || rectangleSize != nil { - throw CLIError.incompatibleOptions( - "--rectangle-color and --rectangle-size require --rectangle.") - } - if highlighterRegions.isEmpty, highlighterColor != nil { - throw CLIError.incompatibleOptions("--highlighter-color requires --highlighter.") - } + try annotations.validate() if imageInputPath == nil, imageFrame != nil || frameAppearance != nil { throw CLIError.incompatibleOptions( "--frame and --frame-appearance require --image.") @@ -246,13 +212,7 @@ extension CLIArgumentParser { || cornerRadius != nil || shadowRadius != nil || wrapColumns != nil || formatCode || watermarkContentRequested - || calloutText != nil - || counterNumber != nil - || !arrowSegments.isEmpty - || !lineSegments.isEmpty - || !rectangleRegions.isEmpty - || !highlighterRegions.isEmpty - || !blurBoxRegions.isEmpty + || annotations.hasContent || showLineNumbers != nil || showChrome != nil || showShadow != nil || highlightedLineRanges != nil || redactedLineRanges != nil || redactSecrets || focusHighlightedLines != nil || diffDecorations != nil @@ -399,38 +359,6 @@ extension CLIArgumentParser { } else { nil } - let calloutPosition: CGPoint? = - if let calloutX, let calloutY { - CGPoint(x: calloutX, y: calloutY) - } else { - nil - } - let counterPosition: CGPoint? = - if let counterX, let counterY { - CGPoint(x: counterX, y: counterY) - } else { - nil - } - let arrows = arrowSegments.map { - CLIOptions.SegmentAnnotation( - start: $0.start, end: $0.end, color: arrowColor, size: arrowSize) - } - let lines = lineSegments.map { - CLIOptions.SegmentAnnotation( - start: $0.start, end: $0.end, color: lineColor, size: lineSize) - } - let rectangles = rectangleRegions.map { - CLIOptions.SegmentAnnotation( - start: $0.start, end: $0.end, color: rectangleColor, size: rectangleSize) - } - let highlighters = highlighterRegions.map { - CLIOptions.SegmentAnnotation( - start: $0.start, end: $0.end, color: highlighterColor, size: nil) - } - let blurBoxes = blurBoxRegions.map { - CLIOptions.SegmentAnnotation( - start: $0.start, end: $0.end, color: nil, size: nil) - } let resolvedMultiSizePresetIDs = mode == .multiSize ? ExportPreset.all.filter { @@ -477,19 +405,19 @@ extension CLIArgumentParser { watermarkColor: watermarkColor, watermarkPosition: watermarkPosition, watermarkFreePosition: watermarkFreePosition, - calloutText: calloutText, - calloutPosition: calloutPosition, - calloutColor: calloutColor, - calloutSize: calloutSize, - counterNumber: counterNumber, - counterPosition: counterPosition, - counterColor: counterColor, - counterSize: counterSize, - arrows: arrows, - lines: lines, - rectangles: rectangles, - highlighters: highlighters, - blurBoxes: blurBoxes, + calloutText: annotations.calloutText, + calloutPosition: annotations.callout.position, + calloutColor: annotations.callout.color, + calloutSize: annotations.callout.size, + counterNumber: annotations.counterNumber, + counterPosition: annotations.counter.position, + counterColor: annotations.counter.color, + counterSize: annotations.counter.size, + arrows: annotations.arrows.materialized, + lines: annotations.lines.materialized, + rectangles: annotations.rectangles.materialized, + highlighters: annotations.highlighters.materialized, + blurBoxes: annotations.blurBoxes.materialized, imageFrame: imageFrame, frameAppearance: frameAppearance, noOverwrite: noOverwrite, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 581bb1db..1ffd95ed 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -23,6 +23,24 @@ Security or StoreKit. Hostless token tests pin the wire format and rejection beh app/CLI entitlement tests continue to exercise each consumer. The source-ownership guard checks the CLI source roots, while compilation proves it links the shared public APIs. +## CLI annotation boundary + +`CLIArgumentSchema` remains the source of public flag identity, aliases, availability, +and help. `CLIArgumentParser` consumes tokens in argv order and resolves syntax using +its existing value validators. `CLIAnnotationArguments` owns only invocation-local +annotation state, modifier compatibility, and projection into `CLIOptions`. + +Point annotations share position/style rules; geometric groups keep their own points +and final per-kind style. Repeated marks retain order within each kind, while a later +style flag applies to every mark of that kind, including earlier marks. The render +model keeps its existing canonical order: callout, counter, arrows, lines, rectangles, +highlighters, then blur boxes. Blur remains visual only and does not sanitize sidecars. + +Annotation semantic validation runs at the same position in the broader compatibility +pass and in the same kind order, so multiple invalid options keep their established +first diagnostic. Token syntax errors still occur before that pass. No CLI flag, default, +output type, recipe precedence, entitlement boundary, or supported API changes. + ## Product and distribution boundaries Vitrine has one open-core product contract, not separate demo and paid render engines.