diff --git a/cli/azd/extensions/azure.ai.content-understanding/CHANGELOG.md b/cli/azd/extensions/azure.ai.content-understanding/CHANGELOG.md index 908321bd95c..e7bf53ea29a 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.content-understanding/CHANGELOG.md @@ -1,6 +1,20 @@ # Release History -## 0.1.1-preview (Unreleased) +## 0.1.2-preview (Unreleased) + +### Features Added + +- Add `azd ai cu analyze --pretty` (`-p`) to pretty-print a single result in the + terminal, with formatted Markdown, colored headings, and simple HTML tables + displayed as tables or labeled cards. + +### Other Changes + +- Improve the readability of `azd ai cu version` text output. +- Highlight commands, paths, and identifiers in infrastructure generation + guidance, and use blue text for wizard questions. Respect `NO_COLOR`. + +## 0.1.1-preview (2026-11-03) ### Features Added diff --git a/cli/azd/extensions/azure.ai.content-understanding/README.md b/cli/azd/extensions/azure.ai.content-understanding/README.md index 061017ce9a4..1fae5d7b10b 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/README.md +++ b/cli/azd/extensions/azure.ai.content-understanding/README.md @@ -188,6 +188,14 @@ Analysis displays YAML front matter and Markdown in the console by default. azd ai cu analyze invoice.pdf --output-file invoice.result.md ``` +By default, stdout contains Markdown and HTML tables as text, including when +redirected. Use `azd ai cu analyze invoice.pdf --pretty` (or `-p`) to pretty-print results +in the terminal, with formatted Markdown and simple HTML tables displayed as +tables or labeled cards. Colors distinguish headings and tables when enabled. +The flag only applies to one streamed LLM-input +result and cannot be combined with saved-output options or JSON. See +[pretty-print behavior and limitations](docs/usage.md#pretty-print-results-in-the-terminal). + For the complete JSON response, including service warnings and usage when present, use `--output json`. Display it in the console or save it to a file: diff --git a/cli/azd/extensions/azure.ai.content-understanding/cspell.yaml b/cli/azd/extensions/azure.ai.content-understanding/cspell.yaml index 83ec9a362f4..2894edcc756 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/cspell.yaml +++ b/cli/azd/extensions/azure.ai.content-understanding/cspell.yaml @@ -3,6 +3,11 @@ words: - contentunderstanding - noninteractive overrides: + - filename: "internal/preview/{render,metadata}.go" + words: + - goldmark + - rowspan + - yuin - filename: "internal/**/*.go" words: - exterrors diff --git a/cli/azd/extensions/azure.ai.content-understanding/docs/provisioning.md b/cli/azd/extensions/azure.ai.content-understanding/docs/provisioning.md index e1f369f1d3f..924194522f1 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/docs/provisioning.md +++ b/cli/azd/extensions/azure.ai.content-understanding/docs/provisioning.md @@ -83,6 +83,10 @@ for role definitions. ## Interactive user workflow +The generation wizard highlights commands, paths, and identifiers in its guidance, +and uses blue question text to distinguish prompts from explanations. Set +`NO_COLOR=1` for plain text. JSON and silent output modes do not use color. + Sign in first, then use two steps: **generate files**, followed by **deploy resources**. Only the second step creates or changes Azure resources and can incur charges. Have your subscription and intended region ready, and check diff --git a/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md b/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md index 7fa7bb60e52..d218f20c5b1 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md +++ b/cli/azd/extensions/azure.ai.content-understanding/docs/usage.md @@ -5,6 +5,12 @@ development. Start with the [README](https://github.com/Azure/azure-dev/blob/mai For resource creation, permissions, model deployment, and service defaults, use the separate [Microsoft Foundry provisioning guide](https://github.com/Azure/azure-dev/blob/main/cli/azd/extensions/azure.ai.content-understanding/docs/provisioning.md). +## Check the installed extension version + +Run `azd ai cu version` to display the installed extension version and name. +For scripts, use `azd ai cu version --output json`, which returns +the extension ID as `name` and the installed version as `version`. + ## Connect to Microsoft Foundry and check setup You need a Microsoft Foundry resource endpoint and an identity with access to @@ -147,6 +153,50 @@ azd ai cu analyze \ --output-file invoice.result.md ``` +### Pretty-print results in the terminal + +By default, stdout contains Markdown and HTML tables as text. Redirect or pipe +this output to save it or render it with another tool: + +```bash +azd ai cu analyze invoice.pdf > invoice.result.md +``` + +Pretty-print results in the terminal with `--pretty` (or `-p`); omit the flag or +use `--pretty=false` for the default text output: + +```bash +azd ai cu analyze invoice.pdf --pretty +``` + +Pretty-printing formats Markdown for terminal reading. The preview includes +document metadata with `---` delimiters and page comments, and keeps source line +breaks. Reference-style links work across tables. + +HTML tables display all columns and switch to labeled cards when they are too wide +for the terminal. + +When color is enabled, metadata, page comments, and generated row labels are gray. +Headings share a consistent color, tables use a distinct color, and layout notices +are yellow. + +Keep these limitations in mind: + +- Unsupported tables, such as those with merged cells or complex headers, display + an explanatory notice followed by their source HTML. +- Long values can overflow, and some Unicode text or emoji may not align correctly. + Right-to-left text display depends on your terminal. Markdown tables may shorten + long links. +- Preview text removes terminal controls. Links and images are not fetched. +- Piped or redirected previews are uncolored and use an 80-column layout. + +`--pretty` requires a single streamed LLM-input result. It cannot be combined with +`--output json`, `--output none`, `--llm-input=false`, `--usage`, `--dry-run`, +`--output-file`, or `--output-dir`. The flag affects only the streamed preview, +not saved result files or reports. To save Markdown/HTML, omit `--pretty`. + +### Complete JSON response + For the complete JSON response envelope, including the result, service warnings, and usage when present, use `--output json`. These examples display the JSON in the console and save it to a file, respectively: @@ -210,6 +260,13 @@ Missing-resource errors suggest checking the endpoint and available analyzers; creation conflicts suggest inspecting the existing analyzer and choosing an unused ID. Service response bodies and authored content remain hidden. +In an interactive terminal, analysis shows an animated `Analyzing ...` +status in the action color (`NO_COLOR` disables color). Concurrent inputs share +one status line that cycles through active targets. Local targets show the file +name; URL targets omit credentials, query parameters, and fragments. The status +clears before results or errors appear and is omitted for redirected or piped +stdout, JSON/none output, and dry runs. It is never saved in result or report files. + - Batch concurrency defaults to 4; `--concurrency` accepts 1-32. - More than 50 selected inputs require confirmation or explicit `--yes`. - Existing results fail by default. Use `--on-existing skip` to keep them or diff --git a/cli/azd/extensions/azure.ai.content-understanding/extension.yaml b/cli/azd/extensions/azure.ai.content-understanding/extension.yaml index d18b6e331ba..5fdc06774f9 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/extension.yaml +++ b/cli/azd/extensions/azure.ai.content-understanding/extension.yaml @@ -12,4 +12,4 @@ tags: - ai - content-understanding usage: azd ai cu [options] -version: 0.1.1-preview +version: 0.1.2-preview diff --git a/cli/azd/extensions/azure.ai.content-understanding/go.mod b/cli/azd/extensions/azure.ai.content-understanding/go.mod index 1c06ba51fed..0493700ea40 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/go.mod +++ b/cli/azd/extensions/azure.ai.content-understanding/go.mod @@ -43,9 +43,9 @@ require ( github.com/buger/jsonparser v1.6.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/glamour v1.0.0 // indirect + github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect - github.com/charmbracelet/x/ansi v0.11.9 // indirect + github.com/charmbracelet/x/ansi v0.11.9 github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/slice v0.1.0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect @@ -86,7 +86,7 @@ require ( github.com/theckman/yacspin v0.13.12 // indirect github.com/xo/terminfo v1.2.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - github.com/yuin/goldmark v1.8.6 // indirect + github.com/yuin/goldmark v1.8.6 github.com/yuin/goldmark-emoji v1.0.6 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.47.0 // indirect @@ -97,10 +97,10 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.58.0 // indirect golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect - golang.org/x/net v0.61.0 // indirect + golang.org/x/net v0.61.0 golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 - golang.org/x/term v0.47.0 // indirect + golang.org/x/term v0.47.0 golang.org/x/text v0.43.0 // indirect golang.org/x/time v0.16.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 // indirect diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/analysis/run.go b/cli/azd/extensions/azure.ai.content-understanding/internal/analysis/run.go index 06019fe0a30..c8ff72f7356 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/analysis/run.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/analysis/run.go @@ -24,6 +24,8 @@ type RunOptions struct { Mode cuclient.AnalysisMode Concurrency int Usage bool + // Progress can be called concurrently; implementations must serialize their output. + Progress func(index int, input Input, active bool) error } type Record struct { @@ -205,7 +207,7 @@ func analyzeInputs( return } index := jobs[position] - payload, usage, err := analyzeOne(ctx, client, inputs[index], llm, options) + payload, usage, err := analyzeWithProgress(ctx, client, inputs[index], index, llm, options) // Drain all started jobs before closing the service session, including on cancellation. completed <- outcome{index: index, payload: payload, usage: usage, err: err} } @@ -218,6 +220,21 @@ func analyzeInputs( return completed } +func analyzeWithProgress( + ctx context.Context, client cuclient.Client, input Input, index int, llm bool, options RunOptions, +) (any, json.RawMessage, error) { + if options.Progress != nil { + if err := options.Progress(index, input, true); err != nil { + return nil, nil, err + } + } + payload, usage, err := analyzeOne(ctx, client, input, llm, options) + if options.Progress != nil { + err = errors.Join(err, options.Progress(index, input, false)) + } + return payload, usage, err +} + func analyzeOne( ctx context.Context, client cuclient.Client, input Input, llm bool, options RunOptions, ) (any, json.RawMessage, error) { diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/analysis/run_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/analysis/run_test.go index 75a7f20c75c..3b63a63b517 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/analysis/run_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/analysis/run_test.go @@ -6,18 +6,91 @@ package analysis import ( "context" "encoding/json" + "errors" "fmt" "io" "strings" + "sync" "testing" "azure.ai.content-understanding/internal/cuclient" "azure.ai.content-understanding/internal/exterrors" "azure.ai.content-understanding/internal/testutil" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestExecutionProgressTracksOnlyStartedInputs(t *testing.T) { + inputs := discoverFiles(t, "a.pdf", "b.pdf", "skipped.pdf") + plan, err := PlanOutputs(inputs, OutputOptions{Existing: "error"}) + require.NoError(t, err) + plan.Outputs[2].Skipped = true + var mu sync.Mutex + events := map[int][]bool{} + options := runOptions() + options.Progress = func(index int, input Input, active bool) error { + mu.Lock() + defer mu.Unlock() + assert.Equal(t, inputs[index], input) + events[index] = append(events[index], active) + return nil + } + client := fakeAnalysisClient{analyze: func( + context.Context, cuclient.AnalyzeRequest, + ) (cuclient.AnalyzeResponse, error) { + return cuclient.AnalyzeResponse{JSON: json.RawMessage(terminal)}, nil + }} + _, err = Execute(t.Context(), client, plan, options) + require.NoError(t, err) + require.Equal(t, map[int][]bool{0: {true, false}, 1: {true, false}}, events) +} + +func TestAnalysisProgressCompletesOnFailureAndCancellation(t *testing.T) { + for _, cancelled := range []bool{false, true} { + t.Run(fmt.Sprint(cancelled), func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + input := discoverFiles(t, "invoice.pdf")[0] + var events []bool + options := runOptions() + options.Progress = func(_ int, _ Input, active bool) error { + events = append(events, active) + return nil + } + want := errors.New("analysis failed") + if cancelled { + want = context.Canceled + } + client := fakeAnalysisClient{analyze: func( + context.Context, cuclient.AnalyzeRequest, + ) (cuclient.AnalyzeResponse, error) { + if cancelled { + cancel() + } + return cuclient.AnalyzeResponse{}, want + }} + _, _, err := analyzeWithProgress(ctx, client, input, 0, false, options) + require.ErrorIs(t, err, want) + require.Equal(t, []bool{true, false}, events) + }) + } +} + +func TestAnalysisProgressFailurePreventsRequest(t *testing.T) { + want := errors.New("progress writer failed") + options := runOptions() + options.Progress = func(int, Input, bool) error { return want } + client := fakeAnalysisClient{analyze: func( + context.Context, cuclient.AnalyzeRequest, + ) (cuclient.AnalyzeResponse, error) { + t.Error("must not analyze after progress output fails") + return cuclient.AnalyzeResponse{}, nil + }} + _, _, err := analyzeWithProgress(t.Context(), client, Input{}, 0, false, options) + require.ErrorIs(t, err, want) +} + func TestExecutionFailureDiagnosticsAreActionableAndBounded(t *testing.T) { for _, count := range []int{1, 12} { t.Run(fmt.Sprint(count), func(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze.go index 6737a62aba2..98fec1eaa34 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze.go @@ -4,6 +4,7 @@ package cmd import ( + "context" "fmt" "os" @@ -11,9 +12,12 @@ import ( "azure.ai.content-understanding/internal/config" "azure.ai.content-understanding/internal/cuclient" "azure.ai.content-understanding/internal/exterrors" + "azure.ai.content-understanding/internal/preview" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/fatih/color" "github.com/spf13/cobra" + "golang.org/x/term" ) type analyzeOptions struct { @@ -26,6 +30,7 @@ type analyzeOptions struct { usage bool dryRun bool yes bool + pretty bool } func newAnalyzeCommand(extCtx *azdext.ExtensionContext, open sessionFactory) *cobra.Command { @@ -35,6 +40,8 @@ func newAnalyzeCommand(extCtx *azdext.ExtensionContext, open sessionFactory) *co Long: "Analyze local files, directories, or HTTPS URLs using Microsoft Entra authentication.\n\n" + "Uses prebuilt-layout when no analyzer is specified or configured.\n" + "Results use YAML front matter and Markdown by default. Batches save source-relative result files.\n" + + "Use --pretty (-p) to pretty-print one streamed LLM-input result in the terminal.\n" + + "Without --pretty, stdout retains the original Markdown and HTML tables, including when redirected.\n" + "Use --output json for complete JSON, or --dry-run to inspect the plan without service calls.", Example: ` azd ai cu analyze invoice.pdf Analyze one file using the configured analyzer, or prebuilt-layout if unset. @@ -52,7 +59,10 @@ func newAnalyzeCommand(extCtx *azdext.ExtensionContext, open sessionFactory) *co Analyze matching PDF files recursively and preserve their relative paths. azd ai cu analyze invoice.pdf --llm-input --output-file invoice.md - Save YAML front matter and Markdown for generative AI model input.`, + Save YAML front matter and Markdown for generative AI model input. + + azd ai cu analyze invoice.pdf --pretty + Pretty-print Markdown and tables in the terminal; wide tables use labeled cards.`, RunE: func(cmd *cobra.Command, args []string) error { return exterrors.FromError(runAnalyzeCommand(cmd, extCtx, open, options, args)) }, @@ -73,6 +83,8 @@ func newAnalyzeCommand(extCtx *azdext.ExtensionContext, open sessionFactory) *co flags.BoolVar(&options.usage, "usage", false, "Include usage metadata in streamed results or per-input summaries") flags.BoolVar(&options.output.LLMInput, "llm-input", true, "Render YAML front matter and Markdown; set false for complete JSON; true conflicts with --output json") + flags.BoolVarP(&options.pretty, "pretty", "p", false, + "Pretty-print one streamed LLM-input result in the terminal (default: original text)") flags.StringVar(&options.output.File, "output-file", "", "Save one result to this file") flags.StringVarP(&options.output.Directory, "output-dir", "d", "", "Save source-relative result files in this directory") flags.StringVar(&options.output.Existing, "on-existing", "", @@ -103,6 +115,25 @@ func runAnalyzeCommand( } options.output.LLMInput = false } + if options.pretty { + for _, conflict := range []struct { + set bool + name string + }{ + {extCtx.OutputFormat != "default", "--output " + extCtx.OutputFormat}, + {!options.output.LLMInput, "--llm-input=false"}, + {options.usage, "--usage"}, + {options.dryRun, "--dry-run"}, + {cmd.Flags().Changed("output-file"), "--output-file"}, + {cmd.Flags().Changed("output-dir"), "--output-dir"}, + } { + if conflict.set { + return commandError("conflicting_analyze_options", + "--pretty cannot be combined with "+conflict.name+".", + "Omit --pretty to keep original text, saved results, or structured output.") + } + } + } if options.dryRun && options.yes { return commandError("dry_run_confirmation", "--dry-run cannot be combined with --yes.", "Remove --yes when planning.") @@ -140,6 +171,11 @@ func runAnalyzeCommand( if err != nil { return err } + if options.pretty && len(plan.Outputs) != 1 { + return commandError("conflicting_analyze_options", + "--pretty requires one input and cannot preview a batch.", + "Select one input, or omit --pretty to save batch results.") + } if err := ctx.Err(); err != nil { return err } @@ -182,9 +218,13 @@ func runAnalyzeCommand( } } } - execution, err := analysis.Execute(ctx, session.client, plan, analysis.RunOptions{ - Analyzer: session.analyzer, Mode: mode, Concurrency: options.concurrency, Usage: options.usage, - }) + execution, err := executeAnalysis(ctx, cmd.OutOrStdout(), extCtx.OutputFormat, + func(ctx context.Context, progress func(int, analysis.Input, bool) error) (analysis.Execution, error) { + return analysis.Execute(ctx, session.client, plan, analysis.RunOptions{ + Analyzer: session.analyzer, Mode: mode, Concurrency: options.concurrency, Usage: options.usage, + Progress: progress, + }) + }) if err != nil { return err } @@ -193,6 +233,18 @@ func runAnalyzeCommand( if err := ctx.Err(); err != nil { return err } + if options.pretty { + width, useColor := 80, false + if file, ok := cmd.OutOrStdout().(*os.File); ok && azdext.IsInteractiveTerminal(file) { + var err error + width, _, err = term.GetSize(int(file.Fd())) //nolint:gosec // File descriptor fits in int. + if err != nil { + return fmt.Errorf("read preview terminal size: %w", err) + } + useColor = !color.NoColor + } + return preview.Render(ctx, cmd.OutOrStdout(), text, width, useColor) + } if _, err := fmt.Fprintln(cmd.OutOrStdout(), text); err != nil { return fmt.Errorf("write CU result: %w", err) } diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_progress.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_progress.go new file mode 100644 index 00000000000..cfa01b063d9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_progress.go @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "maps" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "time" + "unicode" + + "azure.ai.content-understanding/internal/analysis" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/charmbracelet/x/ansi" + "golang.org/x/term" +) + +type analysisProgress struct { + mu sync.Mutex + writer io.Writer + width int + active map[int]string + frame int + visible bool + err error + cancel context.CancelFunc + done chan struct{} + ctx context.Context +} + +func progressTerminal(writer io.Writer, format string) (*os.File, bool) { + file, ok := writer.(*os.File) + return file, ok && format == "default" && azdext.IsInteractiveTerminal(file) +} + +func newAnalysisProgress(ctx context.Context, writer io.Writer, width int) *analysisProgress { + ctx, cancel := context.WithCancel(ctx) + progress := &analysisProgress{ + writer: writer, width: width, active: map[int]string{}, cancel: cancel, done: make(chan struct{}), ctx: ctx, + } + go func() { + defer close(progress.done) + ticker := time.NewTicker(120 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + progress.mu.Lock() + progress.draw(-1) + progress.mu.Unlock() + } + } + }() + return progress +} + +func (p *analysisProgress) update(index int, input analysis.Input, active bool) error { + p.mu.Lock() + defer p.mu.Unlock() + if active { + p.active[index] = analysisTarget(input) + } else { + delete(p.active, index) + } + if !active { + index = -1 + } + p.draw(index) + return p.err +} + +func (p *analysisProgress) draw(index int) { + if p.err != nil || len(p.active) == 0 && !p.visible { + return + } + line := "\r\x1b[2K" + if len(p.active) != 0 { + if index < 0 { + indices := slices.Sorted(maps.Keys(p.active)) + index = indices[(p.frame/4)%len(indices)] + } + target := p.active[index] + label := fmt.Sprintf("%c Analyzing %s...", "|/-\\"[p.frame%4], target) + line += actionText("default", ansi.Truncate(label, max(1, p.width-1), "...")) + p.frame++ + } + n, err := io.WriteString(p.writer, line) + if err == nil && n != len(line) { + err = io.ErrShortWrite + } + if err != nil { + p.err = fmt.Errorf("write analysis progress: %w", err) + p.cancel() + return + } + p.visible = len(p.active) != 0 +} + +func (p *analysisProgress) stop() error { + p.cancel() + <-p.done + p.mu.Lock() + defer p.mu.Unlock() + clear(p.active) + p.draw(-1) + return p.err +} + +func analysisTarget(input analysis.Input) string { + target := filepath.Base(input.Path) + if input.URL != "" { + target, _, _ = strings.Cut(analysis.RedactURL(input.URL), "?") + } + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) { + return -1 + } + return r + }, ansi.Strip(target)) +} + +func executeAnalysis( + ctx context.Context, cmdWriter io.Writer, format string, + run func(context.Context, func(int, analysis.Input, bool) error) (analysis.Execution, error), +) (analysis.Execution, error) { + file, enabled := progressTerminal(cmdWriter, format) + if !enabled { + return run(ctx, nil) + } + width, _, err := term.GetSize(int(file.Fd())) //nolint:gosec // File descriptor fits in int. + if err != nil { + return analysis.Execution{}, fmt.Errorf("read progress terminal size: %w", err) + } + progress := newAnalysisProgress(ctx, cmdWriter, width) + result, runErr := run(progress.ctx, progress.update) + if err := progress.stop(); err != nil { + return result, errors.Join(&azdext.LocalError{ + Code: "progress_write_failed", Category: azdext.LocalErrorCategoryInternal, + Message: "Analysis progress could not be written to the terminal.", + Suggestion: "Check the terminal or redirect stdout to disable progress before retrying.", + }, runErr, err) + } + return result, runErr +} diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_progress_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_progress_test.go new file mode 100644 index 00000000000..571cfc91b42 --- /dev/null +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_progress_test.go @@ -0,0 +1,238 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "azure.ai.content-understanding/internal/analysis" + "azure.ai.content-understanding/internal/cuclient" + + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAnalysisTarget(t *testing.T) { + for _, tt := range []struct { + name string + input analysis.Input + want string + }{ + {"file", analysis.Input{Path: filepath.Join("private", "invoice.pdf")}, "invoice.pdf"}, + {"controls", analysis.Input{Path: "invoice\x1b[31m\n\u202e.pdf"}, "invoice.pdf"}, + {"URL", analysis.Input{ //nolint:gosec // Deliberately fake credentials for nondisclosure coverage. + URL: "https://user:password@example.com/invoice.pdf?sig=secret#fragment", + }, "https://example.com/invoice.pdf"}, + {"empty query", analysis.Input{URL: "https://example.com/invoice.pdf?"}, "https://example.com/invoice.pdf"}, + {"invalid URL", analysis.Input{URL: "https://user:password@%zz?sig=secret"}, "[invalid URL]"}, + } { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, analysisTarget(tt.input)) + }) + } +} + +func TestAnalysisProgressLifecycle(t *testing.T) { + t.Setenv("NO_COLOR", "1") + var buffer bytes.Buffer + progress := newAnalysisProgress(t.Context(), &buffer, 100) + for index, target := range []string{"a.pdf", "b.pdf"} { + require.NoError(t, progress.update(index, analysis.Input{Path: target}, true)) + } + require.Eventually(t, func() bool { + progress.mu.Lock() + defer progress.mu.Unlock() + return progress.frame >= 4 + }, time.Second*3, time.Millisecond*10) + require.NoError(t, progress.update(0, analysis.Input{}, false)) + require.NoError(t, progress.update(1, analysis.Input{}, false)) + require.NoError(t, progress.stop()) + output := buffer.String() + assert.Contains(t, output, "Analyzing a.pdf...") + assert.Contains(t, output, "Analyzing b.pdf...") + assert.Contains(t, output, "| Analyzing") + assert.Contains(t, output, "/ Analyzing") + withoutCursor := strings.ReplaceAll(output, "\r\x1b[2K", "") + assert.Equal(t, withoutCursor, ansi.Strip(withoutCursor)) + assert.True(t, strings.HasSuffix(output, "\r\x1b[2K")) +} + +func TestAnalysisProgressParallelAndSafe(t *testing.T) { + t.Setenv("NO_COLOR", "1") + var buffer bytes.Buffer + progress := newAnalysisProgress(t.Context(), &buffer, 100) + var workers sync.WaitGroup + for index := range 12 { + workers.Go(func() { + err := progress.update(index, analysis.Input{ //nolint:gosec // Fake credentials for nondisclosure coverage. + URL: "https://user:password@example.com/invoice.pdf?sig=secret#fragment", + }, true) + assert.NoError(t, err) + assert.NoError(t, progress.update(index, analysis.Input{}, false)) + }) + } + workers.Wait() + require.NoError(t, progress.stop()) + output := buffer.String() + assert.GreaterOrEqual(t, strings.Count(output, "Analyzing https://example.com/invoice.pdf..."), 12) + for _, secret := range []string{"user", "password", "sig=", "secret", "fragment"} { + assert.NotContains(t, output, secret) + } + assert.True(t, bytes.HasSuffix(buffer.Bytes(), []byte("\r\x1b[2K"))) +} + +type progressErrorWriter struct{ err error } + +func (w progressErrorWriter) Write([]byte) (int, error) { return 0, w.err } + +func TestAnalysisProgressWriteFailure(t *testing.T) { + for _, want := range []error{io.ErrClosedPipe, io.ErrShortWrite} { + t.Run(want.Error(), func(t *testing.T) { + writerErr := want + if errors.Is(want, io.ErrShortWrite) { + writerErr = nil + } + progress := newAnalysisProgress(t.Context(), progressErrorWriter{writerErr}, 80) + require.ErrorIs(t, progress.update(0, analysis.Input{Path: "invoice.pdf"}, true), want) + require.ErrorIs(t, progress.ctx.Err(), context.Canceled) + require.ErrorIs(t, progress.stop(), want) + }) + } +} + +func TestAnalysisProgressEmptyAndCancelled(t *testing.T) { + var buffer bytes.Buffer + progress := newAnalysisProgress(t.Context(), &buffer, 80) + require.NoError(t, progress.stop()) + assert.Empty(t, buffer.String()) + + ctx, cancel := context.WithCancel(t.Context()) + progress = newAnalysisProgress(ctx, &buffer, 80) + require.NoError(t, progress.update(0, analysis.Input{Path: "invoice.pdf"}, true)) + cancel() + require.NoError(t, progress.stop()) + assert.True(t, strings.HasSuffix(buffer.String(), "\r\x1b[2K")) +} + +func TestAnalysisProgressColorAndWidth(t *testing.T) { + setHelpColor(t, true) + for _, noColor := range []string{"", "1"} { + t.Run("NO_COLOR="+noColor, func(t *testing.T) { + t.Setenv("NO_COLOR", noColor) + var buffer bytes.Buffer + progress := newAnalysisProgress(t.Context(), &buffer, 40) + require.NoError(t, progress.update(0, analysis.Input{Path: strings.Repeat("x", 100) + ".pdf"}, true)) + require.NoError(t, progress.stop()) + output := strings.ReplaceAll(buffer.String(), "\r\x1b[2K", "") + assert.LessOrEqual(t, ansi.StringWidth(output), 39) + assert.Contains(t, ansi.Strip(output), "Analyzing") + if noColor == "" { + assert.Contains(t, output, "\x1b[94m") + } else { + assert.Equal(t, output, ansi.Strip(output)) + } + }) + } +} + +func TestAnalysisProgressRedirected(t *testing.T) { + file, err := os.CreateTemp(t.TempDir(), "output") + require.NoError(t, err) + defer file.Close() + read, write, err := os.Pipe() + require.NoError(t, err) + defer read.Close() + defer write.Close() + var buffer bytes.Buffer + for _, writer := range []io.Writer{file, write, &buffer} { + for _, format := range []string{"default", "json", "none"} { + called := false + _, err := executeAnalysis(t.Context(), writer, format, + func(_ context.Context, progress func(int, analysis.Input, bool) error) (analysis.Execution, error) { + called = true + assert.Nil(t, progress) + return analysis.Execution{}, nil + }) + require.NoError(t, err) + assert.True(t, called) + } + } + assert.Empty(t, buffer.String()) + info, err := file.Stat() + require.NoError(t, err) + assert.Zero(t, info.Size()) +} + +func TestAnalysisProgressTTY(t *testing.T) { + terminal, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) + if err != nil { + t.Skip("requires a controlling terminal") + } + defer terminal.Close() + for _, format := range []string{"json", "none"} { + _, enabled := progressTerminal(terminal, format) + assert.False(t, enabled) + } + _, enabled := progressTerminal(terminal, "default") + require.True(t, enabled) + _, err = executeAnalysis(t.Context(), terminal, "default", + func(_ context.Context, progress func(int, analysis.Input, bool) error) (analysis.Execution, error) { + require.NotNil(t, progress) + require.NoError(t, progress(0, analysis.Input{Path: "invoice.pdf"}, true)) + require.NoError(t, progress(0, analysis.Input{}, false)) + return analysis.Execution{}, nil + }) + require.NoError(t, err) + + for _, remote := range []bool{false, true} { + t.Run(fmt.Sprint("command/remote=", remote), func(t *testing.T) { + fixture := newAnalyzeFixture(t) + directory := t.TempDir() + input := filepath.Join(directory, "invoice.pdf") + require.NoError(t, os.WriteFile(input, []byte("%PDF-1.7\n"), 0o600)) + if remote { + input = "https://example.com/invoice.pdf?sig=private-signature#private-fragment" + } + fixture.client.call = func( + _ context.Context, request cuclient.AnalyzeRequest, + ) (cuclient.AnalyzeResponse, error) { + if remote { + assert.Equal(t, input, request.Input.URL, "redaction must not change the service request") + } + return cuclient.AnalyzeResponse{JSON: json.RawMessage(analyzeEnvelope)}, nil + } + root := fixture.root() + root.SetOut(terminal) + root.SetErr(io.Discard) + results := filepath.Join(directory, "results") + report := filepath.Join(directory, "report.json") + root.SetArgs([]string{"analyze", input, "--output-dir", results, "--report-file", report, "--no-prompt"}) + require.NoError(t, root.ExecuteContext(t.Context())) + assert.EqualValues(t, 1, fixture.client.calls.Load()) + files, err := filepath.Glob(filepath.Join(results, "*")) + require.NoError(t, err) + require.Len(t, files, 1) + for _, path := range append(files, report) { + body, err := os.ReadFile(path) //nolint:gosec // Paths are confined to this test's temporary directory. + require.NoError(t, err) + assert.NotContains(t, string(body), "Analyzing") + assert.NotContains(t, string(body), "\x1b") + assert.NotContains(t, string(body), "private-signature") + assert.NotContains(t, string(body), "private-fragment") + } + }) + } +} diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_render_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_render_test.go new file mode 100644 index 00000000000..aa4cb5d53fc --- /dev/null +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/analyze_render_test.go @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "azure.ai.content-understanding/internal/analysis" + "azure.ai.content-understanding/internal/cuclient" + "azure.ai.content-understanding/internal/testutil" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const previewMarkdown = "# Summary\n\n**Important** and *styled*.\n\n" + + "\n\n\n
NameValue
Example42
\n" + +func previewResponse(t *testing.T, fixture *analyzeFixture) json.RawMessage { + t.Helper() + body, err := json.Marshal(map[string]any{ + "status": "Succeeded", + "result": map[string]any{"contents": []any{map[string]any{"kind": "document", "markdown": previewMarkdown}}}, + }) + require.NoError(t, err) + fixture.client.call = func(context.Context, cuclient.AnalyzeRequest) (cuclient.AnalyzeResponse, error) { + return cuclient.AnalyzeResponse{JSON: body}, nil + } + return body +} + +func TestAnalyzePrettyFlagContract(t *testing.T) { + root := NewRootCommand() + command, _, err := root.Find([]string{"analyze"}) + require.NoError(t, err) + require.NotNil(t, command.Flags().Lookup("pretty")) + assert.Same(t, command.Flags().Lookup("pretty"), command.Flags().ShorthandLookup("p")) + setHelpColor(t, false) + assert.Contains(t, renderHelp(t, command), "-p, --pretty") + assert.Nil(t, command.Flags().Lookup("render")) + + fixture := newAnalyzeFixture(t) + _, _, err = fixture.execute(t.Context(), "https://example.com/doc.pdf", "--render") + require.Error(t, err) + assert.Zero(t, fixture.opens) + assert.Zero(t, fixture.client.calls.Load()) +} + +func TestAnalyzeRenderingIsOptIn(t *testing.T) { + for _, flags := range [][]string{ + nil, {"--pretty=false"}, {"-p=false"}, {"--pretty"}, {"-p"}, {"--pretty=true"}, {"-p=true"}, + } { + t.Run(strings.Join(flags, " "), func(t *testing.T) { + fixture := newAnalyzeFixture(t) + body := previewResponse(t, fixture) + stdout, stderr, err := fixture.execute(t.Context(), append([]string{"https://example.com/doc.pdf"}, flags...)...) + require.NoError(t, err) + assert.Empty(t, stderr) + assert.NotContains(t, stdout, "\x1b", "injected/redirected output must not contain ANSI") + assert.EqualValues(t, 1, fixture.client.calls.Load()) + if len(flags) != 0 && !strings.HasSuffix(flags[0], "=false") { + assert.NotContains(t, stdout, "Preview only") + assert.Contains(t, stdout, "Important") + assert.Contains(t, stdout, "Example") + assert.NotContains(t, stdout, "") + assert.Contains(t, stdout, "**Important**", "uncolored previews retain ASCII emphasis markers") + } else { + var envelope struct{ Result json.RawMessage } + require.NoError(t, json.Unmarshal(body, &envelope)) + expected, err := analysis.RenderLLM(envelope.Result) + require.NoError(t, err) + assert.Equal(t, expected+"\n", stdout) + assert.Contains(t, stdout, "
") + } + }) + } +} + +func TestAnalyzeRenderRejectsConflictsBeforeAuthentication(t *testing.T) { + for _, pretty := range []string{"--pretty", "-p"} { + for _, flags := range [][]string{ + {"--output", "json"}, {"--output", "none"}, {"--llm-input=false"}, {"--usage"}, {"--dry-run"}, + {"--output-file", "result.md"}, {"--output-dir", "results"}, {"--output-file="}, {"--output-dir="}, + } { + t.Run(pretty+" "+strings.Join(flags, " "), func(t *testing.T) { + fixture := newAnalyzeFixture(t) + t.Chdir(testutil.TempDir(t)) + args := append([]string{"https://example.com/doc.pdf", pretty}, flags...) + stdout, stderr, err := fixture.execute(t.Context(), args...) + require.ErrorContains(t, err, "--pretty") + assert.Empty(t, stdout) + assert.Empty(t, stderr) + assert.Zero(t, fixture.opens) + assert.Zero(t, fixture.client.calls.Load()) + entries, err := os.ReadDir(".") + require.NoError(t, err) + assert.Empty(t, entries) + }) + } + fixture := newAnalyzeFixture(t) + first, second := schemaFile(t, "first"), schemaFile(t, "second") + _, _, err := fixture.execute(t.Context(), first, second, pretty) + require.ErrorContains(t, err, "batch") + assert.Zero(t, fixture.opens) + assert.Zero(t, fixture.client.calls.Load()) + } +} + +func TestAnalyzeOriginalFilesAndJSONRemainUnchanged(t *testing.T) { + for _, format := range []string{"default", "json"} { + fixture := newAnalyzeFixture(t) + body := previewResponse(t, fixture) + path := filepath.Join(testutil.TempDir(t), "result.md") + _, stderr, err := fixture.execute(t.Context(), "https://example.com/doc.pdf", + "--pretty=false", "--output-file", path, "--output", format) + require.NoError(t, err) + assert.Empty(t, stderr) + saved, err := os.ReadFile(path) // #nosec G304 -- Fixed filename inside the test's private temporary directory. + require.NoError(t, err) + if format == "json" { + assert.JSONEq(t, string(body), string(saved)) + } else { + var envelope struct{ Result json.RawMessage } + require.NoError(t, json.Unmarshal(body, &envelope)) + expected, err := analysis.RenderLLM(envelope.Result) + require.NoError(t, err) + assert.Equal(t, expected, string(saved)) + } + assert.NotContains(t, string(saved), "Preview only") + } +} + +func TestAnalyzeRedirectedStdout(t *testing.T) { + for _, render := range []bool{false, true} { + fixture := newAnalyzeFixture(t) + body := previewResponse(t, fixture) + file, err := os.CreateTemp(testutil.TempDir(t), "stdout-*.md") + require.NoError(t, err) + root := fixture.root() + var stderr bytes.Buffer + root.SetOut(file) + root.SetErr(&stderr) + flags := []string{"analyze", "https://example.com/doc.pdf"} + if render { + flags = append(flags, "--pretty") + } + root.SetArgs(flags) + runErr := root.ExecuteContext(t.Context()) + require.NoError(t, file.Close()) + require.NoError(t, runErr) + result, err := os.ReadFile(file.Name()) + require.NoError(t, err) + assert.Empty(t, stderr.String()) + assert.NotContains(t, string(result), "\x1b") + if render { + assert.NotContains(t, string(result), "Preview only") + assert.Contains(t, string(result), "Important") + assert.NotContains(t, string(result), "
") + } else { + var envelope struct{ Result json.RawMessage } + require.NoError(t, json.Unmarshal(body, &envelope)) + expected, err := analysis.RenderLLM(envelope.Result) + require.NoError(t, err) + assert.Equal(t, expected+"\n", string(result)) + } + } +} + +func TestAnalyzeRenderedPreviewKeepsReportContract(t *testing.T) { + fixture := newAnalyzeFixture(t) + previewResponse(t, fixture) + path := filepath.Join(testutil.TempDir(t), "report.json") + stdout, stderr, err := fixture.execute(t.Context(), "https://example.com/doc.pdf", "--pretty", "--report-file", path) + require.NoError(t, err) + assert.Empty(t, stderr) + assert.NotContains(t, stdout, "Preview only") + assert.Contains(t, stdout, "Example") + report, err := os.ReadFile(path) // #nosec G304 -- Fixed filename inside the test's private temporary directory. + require.NoError(t, err) + var parsed map[string]any + require.NoError(t, json.Unmarshal(report, &parsed)) + assert.Equal(t, "cu-cli/analyze-report/v1", parsed["schema"]) + assert.Equal(t, "llm-input", parsed["result_view"]) + assert.NotContains(t, string(report), "Preview only") +} diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/diagnostics.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/diagnostics.go index 0260e6c3933..c243260a55d 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/diagnostics.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/diagnostics.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "os" + "strings" "sync" "azure.ai.content-understanding/internal/config" @@ -38,6 +39,14 @@ func actionText(format, text string) string { return output.WithHighLightFormat("%s", text) } +func guidanceText(format, text string) string { + parts := strings.Split(text, "`") + for i := 1; i < len(parts)-1; i += 2 { + parts[i] = actionText(format, parts[i]) + } + return strings.Join(parts, "`") +} + func commentText(format, text string) string { if format == "json" || format == "none" || os.Getenv("NO_COLOR") != "" { return text diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra.go index d2c530f8f25..87d9b5d44da 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra.go @@ -144,14 +144,14 @@ func runInfraGenerate( showGuidance := !extCtx.NoPrompt && extCtx.OutputFormat != "json" && extCtx.OutputFormat != "none" if showGuidance { if _, err := fmt.Fprintf(cmd.OutOrStdout(), - "azd ai cu infra generate -> %s\n\n"+ + guidanceText(extCtx.OutputFormat, "`azd ai cu infra generate` -> %s\n\n"+ "`azd ai cu infra generate` generates an azd template. After the Microsoft Foundry resource "+ "is provisioned, its post-provision script can optionally deploy supported chat completion "+ - "and embeddings models for prebuilt analyzers such as prebuilt-invoice and for custom analyzers.\n"+ - "It doesn't create any Azure resources itself - you run azd up afterwards "+ + "and embeddings models for prebuilt analyzers such as `prebuilt-invoice` and for custom analyzers.\n"+ + "It doesn't create any Azure resources itself - you run `azd up` afterwards "+ "to do the actual provisioning.\n"+ - "Choose the Azure subscription used to provision resources, then configure the deployment.\n\n", - output); err != nil { + "Choose the Azure subscription used to provision resources, then configure the deployment.\n\n"), + actionText(extCtx.OutputFormat, output)); err != nil { return fmt.Errorf("write infrastructure introduction: %w", err) } } diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_host.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_host.go index 276a283c8da..51ae22b23b1 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_host.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_host.go @@ -71,7 +71,8 @@ func (h *azdInfraHost) Subscription( } if !noPrompt { response, err := h.client.Prompt().PromptSubscription(ctx, &azdext.PromptSubscriptionRequest{ - Message: "Select the Azure subscription for the generated Content Understanding project", + Message: actionText(h.outputFormat, + "Select the Azure subscription for the generated Content Understanding project"), }) if err != nil { return nil, err diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts.go index 89c326caec7..8002e2d8927 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts.go @@ -32,7 +32,7 @@ func (h *azdInfraHost) PromptGeneration( if err := ctx.Err(); err != nil { return err } - if _, err := fmt.Fprintf(h.output, "\n%s\n", text); err != nil { + if _, err := fmt.Fprintf(h.output, "\n%s\n", guidanceText(h.outputFormat, text)); err != nil { return fmt.Errorf("write infrastructure guidance: %w", err) } return nil @@ -50,7 +50,7 @@ func (h *azdInfraHost) PromptGeneration( return err } response, err := h.client.Prompt().Prompt(ctx, &azdext.PromptRequest{Options: &azdext.PromptOptions{ - Message: message, HelpMessage: help, DefaultValue: *value, + Message: actionText(h.outputFormat, message), HelpMessage: help, DefaultValue: *value, Required: value == &choices.Environment, }}) if err != nil { @@ -80,9 +80,9 @@ func (h *azdInfraHost) PromptGeneration( if err := promptText(prompts.environment, "Enter your environment name (1-64 letters, numbers, -, _, ., or parentheses)", "Input your azd environment name. An azd environment name:\n"+ - " - lets azd store this deployment's config + outputs under .azure// in the generated directory.\n"+ - " - seeds your Azure resource names (e.g. rg-, proj-).\n"+ - " - keeps separate stacks apart, such as dev, test, or prod.", + " - lets azd store this deployment's config + outputs under `.azure//` in the generated directory.\n"+ + " - seeds your Azure resource names (e.g. `rg-`, `proj-`).\n"+ + " - keeps separate stacks apart, such as `dev`, `test`, or `prod`.", &choices.Environment, "invalid_environment"); err != nil { return choices, err } @@ -93,7 +93,7 @@ func (h *azdInfraHost) PromptGeneration( return choices, err } options := &azdext.SelectOptions{ - Message: "Azure region (where the Foundry resource is created)", + Message: actionText(h.outputFormat, "Azure region (where the Foundry resource is created)"), HelpMessage: "Region for the new Microsoft Foundry resource.", EnableFiltering: new(true), } index := int32(0) @@ -118,9 +118,9 @@ func (h *azdInfraHost) PromptGeneration( "Optional Microsoft Foundry resource name prefix (lowercase letters, numbers, hyphen; blank to skip)", "Microsoft Foundry resource naming\n"+ "The resource name becomes part of the public endpoint host "+ - "(for example, https://.services.ai.azure.com), so it must be globally unique.\n"+ - "If you provide a prefix, azd constructs the resource name as -. "+ - "Without a prefix, it uses aif-.", + "(for example, `https://.services.ai.azure.com`), so it must be globally unique.\n"+ + "If you provide a prefix, azd constructs the resource name as `-`. "+ + "Without a prefix, it uses `aif-`.", &choices.FoundryPrefix, "invalid_foundry_prefix"); err != nil { return choices, err } @@ -134,7 +134,7 @@ func (h *azdInfraHost) PromptGeneration( return choices, err } response, err := h.client.Prompt().Confirm(ctx, &azdext.ConfirmRequest{Options: &azdext.ConfirmOptions{ - Message: "Assign RBAC roles to your user on the Microsoft Foundry resource?", + Message: actionText(h.outputFormat, "Assign RBAC roles to your user on the Microsoft Foundry resource?"), HelpMessage: "Requires permission to create role assignments, such as Owner, User Access Administrator, " + "or Role Based Access Control Administrator. If skipped, ask an administrator to grant access for Entra ID.", DefaultValue: new(false), @@ -156,7 +156,7 @@ func (h *azdInfraHost) PromptGeneration( func (h *azdInfraHost) promptModels(ctx context.Context, models []infra.Model) ([]infra.Model, error) { const message = "Select live CU-supported models to deploy" options := &azdext.MultiSelectOptions{ - Message: message, + Message: actionText(h.outputFormat, message), HelpMessage: "Accept or change the recommended selections. Clear all selections to deploy no models. " + "Select only one version of each model family.", AllowEmptySelection: new(true), EnableFiltering: new(true), @@ -167,7 +167,8 @@ func (h *azdInfraHost) promptModels(ctx context.Context, models []infra.Model) ( if !ok || (local.Code != "ambiguous_model_version" && local.Code != "no_deployable_models") { return nil, err } - options.Message = warningText(h.outputFormat, "No models preselected: "+local.Message) + "\n" + message + options.Message = warningText(h.outputFormat, "No models preselected: "+local.Message) + "\n" + + actionText(h.outputFormat, message) } for _, model := range models { label := fmt.Sprintf("%s | %s | %s", model.Selector(), model.Kind, model.SKU) @@ -204,7 +205,7 @@ func (h *azdInfraHost) promptModels(ctx context.Context, models []infra.Model) ( if err == nil { // A host without a terminal can accept preselected choices at EOF; require an explicit opt-in to deploy. confirmation, err := h.client.Prompt().Confirm(ctx, &azdext.ConfirmRequest{Options: &azdext.ConfirmOptions{ - Message: "Deploy these models: " + strings.Join(selectors, ", ") + "?", + Message: actionText(h.outputFormat, "Deploy these models: "+strings.Join(selectors, ", ")+"?"), HelpMessage: "Model deployment can incur Azure charges. No models will be deployed unless you confirm.", DefaultValue: new(false), }}) diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts_test.go index 2388b12a7cd..9afbe7db2d5 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/infra_prompts_test.go @@ -331,6 +331,11 @@ func TestInfraGenerateSectionsAndActionColors(t *testing.T) { if mode == "NO_COLOR" { assert.NotContains(t, text, "\x1b") } else { + if mode == "default" { + assert.Contains(t, text, "`\x1b[94mazd ai cu infra generate\x1b[0m`") + assert.Contains(t, text, "`\x1b[94mprebuilt-invoice\x1b[0m`") + assert.Contains(t, text, "`\x1b[94mazd up\x1b[0m`") + } assert.Contains(t, text, "\x1b[94mazd auth login\x1b[0m\x1b[90m # (one-time)\x1b[0m") assert.Contains(t, text, "\x1b[94mazd up\x1b[0m\x1b[90m # "+ @@ -343,6 +348,53 @@ func TestInfraGenerateSectionsAndActionColors(t *testing.T) { } } +func TestInfraGuidanceAndQuestionColors(t *testing.T) { + setHelpColor(t, true) + for _, mode := range []string{"default", "NO_COLOR", "json", "none"} { + t.Run(mode, func(t *testing.T) { + t.Setenv("NO_COLOR", "") + if mode == "NO_COLOR" { + t.Setenv("NO_COLOR", "1") + } + format := mode + if mode == "NO_COLOR" { + format = "default" + } + rpc, host, choices := generationPromptFixture(t) + var text bytes.Buffer + host.output, host.outputFormat = &text, format + checkQuestion := func(message string) { + if mode == "default" { + assert.True(t, strings.HasPrefix(message, "\x1b[94m"), message) + assert.True(t, strings.HasSuffix(message, "\x1b[0m"), message) + } else { + assert.NotContains(t, message, "\x1b") + } + } + rpc.promptText = func(req *azdext.PromptRequest) (*azdext.PromptResponse, error) { + checkQuestion(req.GetOptions().GetMessage()) + return &azdext.PromptResponse{Value: req.GetOptions().GetDefaultValue()}, nil + } + rpc.selectOption = func(req *azdext.SelectRequest) (*azdext.SelectResponse, error) { + checkQuestion(req.GetOptions().GetMessage()) + return &azdext.SelectResponse{Value: req.GetOptions().SelectedIndex}, nil + } + rpc.confirm = func(req *azdext.ConfirmRequest) (*azdext.ConfirmResponse, error) { + checkQuestion(req.GetOptions().GetMessage()) + return &azdext.ConfirmResponse{Value: req.GetOptions().DefaultValue}, nil + } + _, err := host.PromptGeneration(t.Context(), choices, generationPrompts{true, true, true, true}) + require.NoError(t, err) + if mode == "default" { + assert.Contains(t, text.String(), "`\x1b[94m.azure//\x1b[0m`") + assert.Contains(t, text.String(), "`\x1b[94m-\x1b[0m`") + } else { + assert.NotContains(t, text.String(), "\x1b") + } + }) + } +} + func TestInfraGenerationHostPrompts(t *testing.T) { rpc, host, choices := generationPromptFixture(t) var output bytes.Buffer @@ -447,9 +499,9 @@ func TestInfraGenerationVisibleGuidance(t *testing.T) { rpc.promptText = func(request *azdext.PromptRequest) (*azdext.PromptResponse, error) { if request.GetOptions().GetRequired() { beforePrompt("Input your azd environment name. An azd environment name:", - "lets azd store this deployment's config + outputs under .azure//") - assert.Contains(t, output.String(), "seeds your Azure resource names (e.g. rg-, proj-)") - assert.Contains(t, output.String(), "keeps separate stacks apart, such as dev, test, or prod") + "lets azd store this deployment's config + outputs under `.azure//`") + assert.Contains(t, output.String(), "seeds your Azure resource names (e.g. `rg-`, `proj-`)") + assert.Contains(t, output.String(), "keeps separate stacks apart, such as `dev`, `test`, or `prod`") assert.Equal(t, "Enter your environment name (1-64 letters, numbers, -, _, ., or parentheses)", request.GetOptions().GetMessage()) } else { @@ -458,7 +510,7 @@ func TestInfraGenerationVisibleGuidance(t *testing.T) { "(lowercase letters, numbers, hyphen; blank to skip)", request.GetOptions().GetMessage()) assert.Contains(t, output.String(), "must be globally unique") assert.Contains(t, output.String(), - "If you provide a prefix, azd constructs the resource name as -.") + "If you provide a prefix, azd constructs the resource name as `-`.") } return &azdext.PromptResponse{Value: request.GetOptions().GetDefaultValue()}, nil } diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/root_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/root_test.go index cfd4cc7e3ec..58596677756 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/root_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/root_test.go @@ -50,7 +50,7 @@ func TestRootWithoutHostOrProject(t *testing.T) { want string }{ {"help", []string{"--help"}, "Azure Content Understanding"}, - {"version", []string{"version"}, extensionID + " " + Version + "\n"}, + {"version", []string{"version"}, "Version: " + Version + "\nExtension name: " + extensionID + "\n\n"}, {"version flag", []string{"--version"}, Version}, {"json version", []string{"version", "--output", "json"}, `"name":"` + extensionID + `"`}, {"metadata", []string{"metadata"}, `"id": "` + extensionID + `"`}, @@ -188,6 +188,14 @@ func TestMetadataUsesRuntimeTree(t *testing.T) { assert.Error(t, extensions.ValidateAgainstSchema(schema, document)) } +func TestVersionTextOutput(t *testing.T) { + stdout, stderr, err := executeRoot(t, "version") + require.NoError(t, err) + assert.Empty(t, stderr) + assert.Equal(t, "\nAzure Content Understanding Extension for azd\nVersion: "+Version+ + "\nExtension name: "+extensionID+"\n\n", stdout) +} + func TestVersionJSONShape(t *testing.T) { stdout, stderr, err := executeRoot(t, "version", "--output", "json") require.NoError(t, err) diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/version.go b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/version.go index 049612938ed..17c8728a468 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/version.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/cmd/version.go @@ -37,7 +37,9 @@ func newVersionCommand(outputFormat *string) *cobra.Command { } return nil } - _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s\n", extensionID, Version) + _, err := fmt.Fprintf(cmd.OutOrStdout(), + "\nAzure Content Understanding Extension for azd\nVersion: %s\nExtension name: %s\n\n", + Version, extensionID) return err }, }, azdext.FlagOptions{Name: "output", AllowedValues: []string{"json"}}) diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/config/config.go b/cli/azd/extensions/azure.ai.content-understanding/internal/config/config.go index 30f14be3f8d..527ec8a9ed1 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/config/config.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/config/config.go @@ -104,7 +104,7 @@ func resolve( if endpoint == "" { return result, invalid("missing_endpoint", "Specify --endpoint or CU_ENDPOINT, or run "+ - "azd ai cu config set CU_ENDPOINT https://.services.ai.azure.com.") + "azd ai cu config set CU_ENDPOINT https://.services.ai.azure.com.") } if result.Endpoint, err = cuclient.NormalizeEndpoint(endpoint); err != nil { return result, err diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/config/config_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/config/config_test.go index cf0706eacbb..c786426191e 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/config/config_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/config/config_test.go @@ -9,6 +9,7 @@ import ( "testing" "azure.ai.content-understanding/internal/cuclient" + "azure.ai.content-understanding/internal/exterrors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -124,6 +125,12 @@ func TestResolveProvisionedScopeAndRuntimeSettings(t *testing.T) { delete(values, "CU_ENDPOINT") _, err := resolve(Options{}, nil, values, nil, nil) require.ErrorContains(t, err, "Specify --endpoint or CU_ENDPOINT") + for range 3 { + err = exterrors.FromError(err) + require.ErrorContains(t, err, + "azd ai cu config set CU_ENDPOINT https://.services.ai.azure.com.") + require.NotContains(t, err.Error(), "[redacted path]") + } } func TestProvisionedValuePresencePrecedence(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/exterrors/errors_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/exterrors/errors_test.go index e1095a0f948..d49aa371ed5 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/exterrors/errors_test.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/exterrors/errors_test.go @@ -22,6 +22,21 @@ import ( "google.golang.org/grpc/status" ) +func TestEndpointExampleDoesNotBypassDiagnosticRedaction(t *testing.T) { + const example = "https://.services.ai.azure.com" + for _, message := range []string{ + "Use " + example + ". https://user:password@example.com/file?sig=secret#fragment", + "payload: " + example + " secret", + "Use " + example + "?sig=secret#fragment", + } { + safe := Redact(message) + for _, secret := range []string{"user:", "password", "sig=", "secret", "#fragment"} { + assert.NotContains(t, safe, secret) + } + assert.Equal(t, safe, Redact(safe)) + } +} + func TestPreservesStructuredErrors(t *testing.T) { hostError, err := status.New(codes.FailedPrecondition, "host message").WithDetails( &azdext.ActionableErrorDetail{Suggestion: "host suggestion"}, diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/exterrors/redact.go b/cli/azd/extensions/azure.ai.content-understanding/internal/exterrors/redact.go index 54ac5348588..2c27829467b 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/internal/exterrors/redact.go +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/exterrors/redact.go @@ -21,12 +21,14 @@ var ( secretValue = regexp.MustCompile( `(?im)\b(?:[a-z_]*token|api[-_]?key|ocp-apim-subscription-key|accountkey|client_secret|` + `password|secret|sas|sig|schema|content|text|payload|body|path)\s*[:=]\s*[^\r\n]*`) - diagnosticURL = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^\s"'<>]+`) - windowsPath = regexp.MustCompile(`(?i)(?:[a-z]:[\\/]|\\\\)[^\s"'<>]+`) - posixPath = regexp.MustCompile(`(^|[\s"'=(])(?:/|~/|\.\.?/)[^\s"'<>]+`) - quotedValue = regexp.MustCompile(`"[^"\r\n]*"|'[^'\r\n]*'`) - fieldPath = regexp.MustCompile(`\b(fieldSchema\.fields|fields|properties)\.[^\s:]+`) - diagnosticCode = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`) + diagnosticURL = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^\s"'<>]+`) + windowsPath = regexp.MustCompile(`(?i)(?:[a-z]:[\\/]|\\\\)[^\s"'<>]+`) + posixPath = regexp.MustCompile(`(^|[\s"'=(])(?:/|~/|\.\.?/)[^\s"'<>]+`) + quotedValue = regexp.MustCompile(`"[^"\r\n]*"|'[^'\r\n]*'`) + fieldPath = regexp.MustCompile(`\b(fieldSchema\.fields|fields|properties)\.[^\s:]+`) + diagnosticCode = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`) + endpointExample = regexp.MustCompile( + `https://\.services\.ai\.azure\.com(\.(?:\s|$)|\s|$)`) ) // Redact is for diagnostics only, never command results or service payloads. @@ -35,6 +37,10 @@ func Redact(message string) string { if len(message) > 64*1024 || !utf8.ValidString(message) { return "Diagnostic omitted because it exceeds the safe text limit." } + const example = "https://.services.ai.azure.com" + const placeholder = "CU_ENDPOINT_EXAMPLE_PLACEHOLDER" + // Protect only the literal help example, not real URLs or URL suffixes. + message = endpointExample.ReplaceAllString(message, placeholder+"${1}") if start := strings.IndexByte(message, '{'); start >= 0 { message = message[:start] + "[redacted payload]" } @@ -51,7 +57,7 @@ func Redact(message string) string { } return r }, message) - message = strings.TrimSpace(message) + message = strings.TrimSpace(strings.ReplaceAll(message, placeholder, example)) if len(message) > MaxDiagnosticBytes { return "Diagnostic omitted because it exceeds the safe text limit." } diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/metadata.go b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/metadata.go new file mode 100644 index 00000000000..ec792005adc --- /dev/null +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/metadata.go @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package preview + +import ( + "fmt" + "regexp" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/text" + "go.yaml.in/yaml/v3" +) + +var ( + metadataStart = regexp.MustCompile(`(?:\A|\n\n\*{5}\n\n)---\n`) + metadataEnd = regexp.MustCompile(`(?m)^---(?:\n|$)`) +) + +// metadataBlocks recognizes the RenderLLM content boundaries, not arbitrary +// horizontal rules. Masking candidate YAML keeps its opaque values out of the +// Markdown parser while retaining fences surrounding literal examples. +func metadataBlocks(source string) ([]text.Segment, error) { + var blocks []text.Segment + masked := []byte(source) + for _, match := range metadataStart.FindAllStringIndex(source, -1) { + start := match[1] - len("---\n") + if len(blocks) != 0 && start < blocks[len(blocks)-1].Stop { + continue + } + body := match[1] + closing := metadataEnd.FindStringIndex(source[body:]) + if closing == nil { + continue + } + end := body + closing[1] + content := source[body : body+closing[0]] + var node yaml.Node + if err := yaml.Unmarshal([]byte(content), &node); err != nil { + continue + } + if len(node.Content) != 1 || node.Content[0].Kind != yaml.MappingNode || + len(node.Content[0].Content) < 2 || node.Content[0].Content[0].Value != "mimeType" { + continue + } + blocks = append(blocks, text.NewSegment(start, end)) + for index := start; index < end; index++ { + if masked[index] != '\n' { + masked[index] = ' ' + } + } + } + if len(blocks) == 0 { + return nil, nil + } + document := goldmark.New().Parser().Parse(text.NewReader(masked)) + var protected []text.Segment + err := ast.Walk(document, func(node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + switch node.(type) { + case *ast.FencedCodeBlock, *ast.CodeBlock, *ast.HTMLBlock: + if lines := node.Lines(); lines.Len() != 0 { + protected = append(protected, text.NewSegment(lines.At(0).Start, lines.At(lines.Len()-1).Stop)) + } + return ast.WalkSkipChildren, nil + default: + return ast.WalkContinue, nil + } + }) + if err != nil { + return nil, fmt.Errorf("locate preview metadata: %w", err) + } + var result []text.Segment + for _, block := range blocks { + literal := false + for _, segment := range protected { + if block.Start >= segment.Start && block.Start < segment.Stop { + literal = true + break + } + } + if literal { + continue + } + result = append(result, block) + } + return result, nil +} diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/metadata_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/metadata_test.go new file mode 100644 index 00000000000..5f4e63d6a70 --- /dev/null +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/metadata_test.go @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package preview + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "azure.ai.content-understanding/internal/analysis" + + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMetadataPreservesOriginalDelimiters(t *testing.T) { + source := "---\nmimeType: application/pdf\npages: 1\n---\n# Title\n\nBody.\n" + blocks, err := metadataBlocks(source) + require.NoError(t, err) + require.Len(t, blocks, 1) + assert.Equal(t, "---\nmimeType: application/pdf\npages: 1\n---\n", source[blocks[0].Start:blocks[0].Stop]) + for _, color := range []bool{false, true} { + var output bytes.Buffer + require.NoError(t, Render(t.Context(), &output, source, 80, color)) + plain := ansi.Strip(output.String()) + assert.NotContains(t, plain, "Metadata") + assert.Contains(t, plain, "---\nmimeType: application/pdf\npages: 1\n---\n") + assert.Contains(t, plain, "mimeType: application/pdf") + assert.NotContains(t, plain, "## mimeType") + assert.Contains(t, plain, "Title") + assert.Contains(t, plain, "Body.") + } +} + +func TestMetadataLeavesMarkdownAndLiteralExamplesAlone(t *testing.T) { + for _, source := range []string{ + "# Title\n\n---\n\nA horizontal rule.\n", + "---\nA setext heading\n---\n", + "---\ntitle: Not LLM metadata\n---\n", + "---\nmimeType: [invalid YAML\n---\n", + "---\nmimeType: missing closing delimiter\n", + "# Examples\n\n```markdown\nExample:\n\n*****\n\n---\nmimeType: text/plain\n---\n```\n", + "# Examples\n\n~~~markdown\nExample:\n\n*****\n\n---\nmimeType: text/plain\n---\n~~~\n", + "\n", + } { + blocks, err := metadataBlocks(source) + require.NoError(t, err) + assert.Empty(t, blocks) + } +} + +func TestMetadataAcrossProjectedContentsAndOpaqueValues(t *testing.T) { + const literalExample = "```markdown\nExample:\n\n*****\n\n---\nmimeType: text/example\n---\n```\n" + const opaque = "A literal fence:\n````\n
Not a document table
\n````" + const bodyTable = "\n\n" + + "\n
NameCount
Document body value42
\n" + payload, err := json.Marshal(map[string]any{ + "contents": []any{ + map[string]any{ + "kind": "document", "mimeType": "application/pdf", + "metadata": map[string]any{"note": opaque}, + "markdown": "# First document\n\n" + literalExample + "\n" + bodyTable, + }, + map[string]any{ + "kind": "audioVisual", "mimeType": "audio/wav", "markdown": "# Second content\n\nTranscript.", + }, + }, + }) + require.NoError(t, err) + original, err := analysis.RenderLLM(payload) + require.NoError(t, err) + blocks, err := metadataBlocks(original) + require.NoError(t, err) + require.Len(t, blocks, 2) + assert.Contains(t, original[blocks[0].Start:blocks[0].Stop], "Not a document table") + assert.Contains(t, original, literalExample, "document code examples must stay literal") + for _, color := range []bool{false, true} { + var result bytes.Buffer + require.NoError(t, Render(t.Context(), &result, original, 120, color)) + plain := ansi.Strip(result.String()) + assert.NotContains(t, plain, "## mimeType") + assert.Contains(t, plain, "mimeType: application/pdf") + assert.Contains(t, plain, "mimeType: audio/wav") + assert.Contains(t, plain, "Not a document table") + assert.Contains(t, plain, "Document body value") + assert.NotContains(t, plain, "HTML table not rendered:") + assert.Contains(t, plain, "Transcript.") + } + assert.True(t, strings.HasPrefix(original, "---\nmimeType: application/pdf\n")) + assert.Contains(t, original, bodyTable) + assert.NotContains(t, original, "**Metadata**") +} diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/multilingual_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/multilingual_test.go new file mode 100644 index 00000000000..98971991bd4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/multilingual_test.go @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package preview + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMultilingualPreview(t *testing.T) { + const identifier = "RECORD0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + for _, sample := range []struct { + language, heading, city, greeting string + }{ + {"zh-TW", "城市", "台北", "你好,世界"}, + {"ja", "都市", "東京", "こんにちは"}, + {"ko", "도시", "서울", "안녕하세요"}, + {"de", "Stadt", "München", "Grüße"}, + {"hi", "शहर", "दिल्ली", "नमस्ते"}, + {"ar", "المدينة", "القاهرة", "مرحبا"}, + } { + t.Run(sample.language, func(t *testing.T) { + source := "# " + sample.language + "\n\n**" + sample.greeting + "**\n\n" + + "\n\n" + + "\n
" + sample.heading + "Description
" + sample.city + "" + identifier + "
\n" + for _, width := range []int{20, 40, 80, 120} { + for _, color := range []bool{false, true} { + t.Run(fmt.Sprintf("width=%d/color=%t", width, color), func(t *testing.T) { + var output bytes.Buffer + require.NoError(t, Render(t.Context(), &output, source, width, color)) + plain := ansi.Strip(output.String()) + assert.Contains(t, plain, sample.heading) + assert.Contains(t, plain, sample.city) + assert.Contains(t, plain, sample.greeting) + assert.Contains(t, plain, identifier) + assert.NotContains(t, plain, "\ufffd") + assert.NotContains(t, plain, "HTML table not rendered:") + if !color { + assert.NotContains(t, output.String(), "\x1b") + } + if width <= 40 { + assert.Contains(t, plain, "Row 1") + positions := map[string]int{} + for line := range strings.SplitSeq(plain, "\n") { + for _, value := range []string{sample.city, identifier} { + if prefix, ok := strings.CutSuffix(line, value); ok { + positions[value] = ansi.StringWidth(prefix) + } + } + } + require.Len(t, positions, 2) + expected := ansi.StringWidth(sample.heading) + 1 + + max(len(sample.heading), len("Description")) - len(sample.heading) + 2 + assert.Equal(t, expected, positions[sample.city]) + assert.Equal(t, max(len(sample.heading), len("Description"))+3, positions[identifier]) + t.Logf("pinned cards: localized value column=%d, ASCII value column=%d", + positions[sample.city], positions[identifier]) + } else { + assert.NotContains(t, plain, "Row 1") + } + }) + } + } + t.Run("table-column-measurements", func(t *testing.T) { + source := "\n\n" + + "\n" + + "\n
" + sample.heading + "Status
" + sample.city + "READY
" + sample.greeting + "DONE
\n" + plain := renderPreview(t, source, 120) + columns := map[string]int{} + for line := range strings.SplitSeq(plain, "\n") { + for _, suffix := range []string{"Status", "READY", "DONE"} { + if prefix, ok := strings.CutSuffix(line, suffix); ok { + columns[suffix] = ansi.StringWidth(prefix) + } + } + } + require.Len(t, columns, 3) + if sample.language == "hi" { + assert.Equal(t, map[string]int{"Status": 8, "READY": 5, "DONE": 7}, columns, + "characterization: pinned azd rune widths differ from ANSI grapheme widths for these Indic strings") + t.Logf("known Hindi table-alignment limitation, zero-based ANSI columns: %v", columns) + return + } + expected := max(ansi.StringWidth(sample.heading), ansi.StringWidth(sample.city), + ansi.StringWidth(sample.greeting)) + 3 + for name, column := range columns { + assert.Equal(t, expected, column, sample.language+"/"+name) + } + }) + }) + } +} diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render.go b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render.go new file mode 100644 index 00000000000..38460a06d8b --- /dev/null +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render.go @@ -0,0 +1,456 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package preview + +import ( + "bytes" + "cmp" + "context" + "fmt" + "io" + "os" + "slices" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/glamour/styles" + "github.com/charmbracelet/x/ansi" + terminalColor "github.com/fatih/color" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/text" + "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +type table struct { + caption string + headers []string + rows [][]string +} + +type previewBlock struct { + start, end int + table bool +} + +// Render writes a human-readable preview without changing the source result. +// Width controls layout selection, not a hard bound on retained long values. +func Render(ctx context.Context, writer io.Writer, source string, width int, color bool) error { + if err := ctx.Err(); err != nil { + return err + } + if width < 1 { + return fmt.Errorf("preview width must be positive") + } + color = color && os.Getenv("NO_COLOR") == "" + tableColor := terminalColor.New(terminalColor.FgCyan) + commentColor := terminalColor.New(terminalColor.FgHiBlack) + warningColor := terminalColor.New(terminalColor.FgYellow) + tableColor.EnableColor() + commentColor.EnableColor() + warningColor.EnableColor() + style := styles.ASCIIStyleConfig + if color { + style = styles.DarkStyleConfig + } + style.H1 = style.H2 + style.H1.Prefix = "# " + renderer, err := glamour.NewTermRenderer( + glamour.WithStyles(style), glamour.WithWordWrap(width), glamour.WithPreservedNewLines(), + ) + if err != nil { + return fmt.Errorf("initialize Markdown preview: %w", err) + } + source = safeText(source) + metadata, err := metadataBlocks(source) + if err != nil { + return err + } + data := []byte(source) + var blocks []previewBlock + for _, segment := range metadata { + blocks = append(blocks, previewBlock{start: segment.Start, end: segment.Stop}) + for index := segment.Start; index < segment.Stop; index++ { + if data[index] != '\n' { + data[index] = ' ' + } + } + } + document := goldmark.New(goldmark.WithExtensions(extension.GFM)).Parser().Parse(text.NewReader(data)) + var references strings.Builder + err = ast.Walk(document, func(node ast.Node, entering bool) (ast.WalkStatus, error) { + if err := ctx.Err(); err != nil { + return ast.WalkStop, err + } + if _, ok := node.(*ast.LinkReferenceDefinition); entering && ok { + references.Write(node.Lines().Value(data)) + references.WriteString("\n\n") + } + return ast.WalkContinue, nil + }) + if err != nil { + return err + } + // Each fragment needs the document's definitions in source order so links + // resolve across tables and duplicate labels retain their first definition. + renderMarkdown := func(fragment string) (string, error) { + return renderer.Render(references.String() + fragment) + } + err = ast.Walk(document, func(node ast.Node, entering bool) (ast.WalkStatus, error) { + if err := ctx.Err(); err != nil { + return ast.WalkStop, err + } + block, ok := node.(*ast.HTMLBlock) + if !entering || !ok || block.Lines().Len() == 0 { + return ast.WalkContinue, nil + } + start := block.Lines().At(0).Start + end := block.Lines().At(block.Lines().Len() - 1).Stop + if block.HasClosure() { + end = max(end, block.ClosureLine.Stop) + } + raw := source[start:end] + isComment := strings.HasPrefix(strings.TrimSpace(raw), "", reason) + if color { + notice = warningColor.Sprint(notice) + } + fmt.Fprintf(&result, "\n%s\n%s\n", notice, raw) + } else { + rendered, cards, err := renderTable(parsed, width) + if err != nil { + return err + } + if cards { + notice := "" + if color { + notice = warningColor.Sprint(notice) + } + result.WriteString(notice + "\n") + } + if parsed.caption != "" { + rendered = parsed.caption + "\n" + rendered + } + if color { + var styled strings.Builder + for line := range strings.SplitAfterSeq(ansi.Strip(rendered), "\n") { + if cards && strings.HasPrefix(strings.TrimSpace(line), " %s", index+1, labels[index])) + } + maxLabelLen = max(maxLabelLen, len(labels[index])) + } + var result strings.Builder + for index, row := range value.rows { + if index > 0 { + result.WriteByte('\n') + } + fmt.Fprintf(&result, "\n", index+1) + for column, cell := range row { + fmt.Fprintf(&result, "%s:%s%s\n", labels[column], + strings.Repeat(" ", maxLabelLen-len(labels[column])+2), cell) + } + } + return result.String() +} diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render_test.go b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render_test.go new file mode 100644 index 00000000000..86776fa41c4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/render_test.go @@ -0,0 +1,596 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package preview + +import ( + "bytes" + "context" + "errors" + "fmt" + "strconv" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func renderPreview(t *testing.T, source string, width int) string { + t.Helper() + var result bytes.Buffer + require.NoError(t, Render(t.Context(), &result, source, width, false)) + require.NotContains(t, result.String(), "\x1b") + return result.String() +} + +func TestMarkdownStylesAndHTMLTable(t *testing.T) { + source := "# Results\n\n**Bold**, *italic*, ***both***.\n\n" + + "\n\n\n" + + "\n
Totals
NameValue
A & B**literal**
\n\nAfter table.\n" + result := renderPreview(t, source, 120) + assert.Contains(t, result, "Bold") + assert.Contains(t, result, "italic") + assert.Contains(t, result, "both") + assert.NotContains(t, result, "***both***") + assert.Contains(t, result, "A & B") + assert.Contains(t, result, "**literal**") + assert.NotContains(t, result, "") + assert.NotContains(t, result, "Row 1") + assert.Less(t, strings.Index(result, "Totals"), strings.Index(result, "A & B")) + assert.Contains(t, result, "After table.") + + var colored bytes.Buffer + require.NoError(t, Render(t.Context(), &colored, "**Bold**\n", 80, true)) + assert.Contains(t, colored.String(), "\x1b") + assert.Contains(t, ansi.Strip(colored.String()), "Bold") +} + +func TestPrettyPreservesDocumentStructure(t *testing.T) { + t.Setenv("NO_COLOR", "") + const metadata = "---\nmimeType: application/pdf\npages: 1\n---\n" + const comment = "" + source := metadata + "# Document title\n\nFirst source line.\nSecond source line.\n\n" + comment + "\n" + for _, color := range []bool{false, true} { + var result bytes.Buffer + require.NoError(t, Render(t.Context(), &result, source, 120, color)) + plain := ansi.Strip(result.String()) + assert.Contains(t, plain, metadata) + assert.Contains(t, plain, comment) + assert.Contains(t, plain, "# Document title") + assert.Contains(t, plain, "Document title") + assert.Regexp(t, `First source line\.\s*\n\s*Second source line\.`, plain) + assert.NotContains(t, plain, "Preview only") + assert.NotContains(t, plain, "URLs omit") + if color { + assert.Contains(t, result.String(), "\x1b[90m---") + assert.Contains(t, result.String(), "\x1b[90m"+comment) + assert.NotContains(t, result.String(), "\x1b[90;2m") + assert.NotContains(t, result.String(), "\x1b[2m") + } + } + commentWithTable := "" + plain := renderPreview(t, commentWithTable+"\n", 120) + assert.Contains(t, plain, commentWithTable) + assert.NotContains(t, plain, "HTML table not rendered:") +} + +func TestH1UsesTheOtherHeadingStyles(t *testing.T) { + t.Setenv("NO_COLOR", "") + const source = "# First\n\n## Second\n\n### Third\n\n#### Fourth\n" + for _, color := range []bool{false, true} { + var result bytes.Buffer + require.NoError(t, Render(t.Context(), &result, source, 120, color)) + plain := ansi.Strip(result.String()) + for _, heading := range []string{"# First", "## Second", "### Third", "#### Fourth"} { + assert.Contains(t, plain, heading) + } + if color { + for line := range strings.SplitSeq(result.String(), "\n") { + for _, title := range []string{"First", "Second", "Third", "Fourth"} { + if strings.Contains(line, title) { + assert.Contains(t, line, "\x1b[38;5;39;1m", "all headings use the shared heading color") + assert.NotContains(t, line, "\x1b[48;", "H1 has no dedicated background") + } + } + } + } + } +} + +func TestPrettyLayoutNoticesAndRowAnnotations(t *testing.T) { + t.Setenv("NO_COLOR", "") + const table = "" + + "" + + "
NameValue
FirstRECORD0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ
SecondRECORD9876543210ABCDEFGHIJKLMNOPQRSTUVWXYZ
" + for _, width := range []int{20, 160} { + var result bytes.Buffer + require.NoError(t, Render(t.Context(), &result, table, width, true)) + plain := ansi.Strip(result.String()) + if width == 20 { + assert.Contains(t, result.String(), "\x1b[33m") + assert.Contains(t, plain, "") + assert.Regexp(t, "\x1b\\[90m[^\\n]*", result.String()) + assert.NotContains(t, result.String(), "\x1b[90;2m") + assert.NotContains(t, result.String(), "\x1b[2m") + } else { + assert.NotContains(t, plain, "Table exceeds terminal width") + assert.NotContains(t, plain, "" + for _, source := range []string{ + table + "\nFootnote immediately after table.\n", + table + "\n" + comment + "\nFootnote immediately after table.\n", + table + "\n" + table + "\nFootnote immediately after table.\n", + } { + result := renderPreview(t, source, 120) + assert.NotContains(t, result, "HTML table not rendered") + assert.NotContains(t, result, "") + assert.Contains(t, result, "Footnote immediately after table.") + assert.Equal(t, strings.Count(source, "SUBTOTAL"), strings.Count(result, "SUBTOTAL")) + if strings.Contains(source, comment) { + assert.Contains(t, result, comment) + } + } +} + +func TestTableFallbackReasons(t *testing.T) { + for _, sample := range []struct { + name, source, reason string + }{ + {"rowspan", "ABC", "merged cells"}, + {"colspan", "ABC", "merged cells"}, + {"multiple headers", "AB", "multiple or mixed header rows"}, + {"mixed headers", "AB", "multiple or mixed header rows"}, + {"headed column mismatch", "ABC", "inconsistent column counts"}, + {"headerless column mismatch", "ABC", "inconsistent column counts"}, + {"empty table", "", "table has no cells"}, + {"empty row", "", "empty row"}, + {"unknown content", "A", "unsupported cell content"}, + } { + t.Run(sample.name, func(t *testing.T) { + source := "" + sample.source + "
" + result := renderPreview(t, source+"\n", 120) + assert.Contains(t, result, "HTML table not rendered: "+sample.reason) + assert.Contains(t, result, source) + }) + } +} + +func TestCardsKeepEmptyFieldsAndIdentifyAmbiguousColumns(t *testing.T) { + for _, sample := range []struct { + name, source string + fields []string + }{ + {"empty fields", "ABValue" + + "", + []string{"", "A: \n", "B: Value", "", "B: \n"}}, + {"headerless fields", "First", + []string{"", ": First", ": \n"}}, + {"ambiguous headings", "ValueValueValue (column 2)" + + "FirstSecondThirdFourth", + []string{"", " Value", " Value", + "Value (column 2)", "First", "Second", "Third", "Fourth"}}, + } { + t.Run(sample.name, func(t *testing.T) { + result := renderPreview(t, ""+sample.source+"
\n", 1) + assert.Contains(t, result, "Table exceeds terminal width") + assert.NotContains(t, result, "HTML table not rendered") + for _, field := range sample.fields { + assert.Contains(t, result, field) + } + }) + } +} + +func TestCodeFenceAndNativeMarkdownTablesStayWithGlamour(t *testing.T) { + source := "```html\n
A
B
\n```\n\n" + + "| Name | Value |\n| --- | --- |\n| Native | 42 |\n" + result := renderPreview(t, source, 120) + assert.Contains(t, result, "") + assert.Contains(t, result, "Native") + assert.Contains(t, result, "42") + assert.NotContains(t, result, "HTML table not rendered:") + assert.NotContains(t, result, "Row 1") +} + +func TestCardsRetainOriginalRowHeadings(t *testing.T) { + source := "
" + + "
Row_RowDetails
OriginalAnotherRECORD0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ
\n" + result := renderPreview(t, source, 20) + for _, value := range []string{"Row 1", "Row:", "_Row:", "Original", "Another"} { + assert.Contains(t, result, value) + } +} + +func TestPinnedCardsUnicodeAlignmentCharacterization(t *testing.T) { + source := "" + + "
城市Description
台北RECORD0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ
\n" + result := renderPreview(t, source, 20) + columns := map[string]int{} + for line := range strings.SplitSeq(result, "\n") { + for _, value := range []string{"台北", "RECORD0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"} { + if prefix, _, ok := strings.Cut(line, value); ok { + columns[value] = ansi.StringWidth(prefix) + } + } + } + require.Len(t, columns, 2) + assert.NotEqual(t, columns["台北"], columns["RECORD0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"], + "azd v1.37.0 cards still pad labels by byte length; this test records, not fixes, the upstream limitation") +} + +type failingWriter struct{ err error } + +func (w failingWriter) Write([]byte) (int, error) { return 0, w.err } + +func TestPreviewErrorsAndCancellation(t *testing.T) { + errWrite := errors.New("writer unavailable") + require.ErrorIs(t, Render(t.Context(), failingWriter{errWrite}, "Result", 80, false), errWrite) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + var result bytes.Buffer + require.ErrorIs(t, Render(ctx, &result, "Result", 80, false), context.Canceled) + assert.Empty(t, result.String()) + require.ErrorContains(t, Render(t.Context(), &result, "Result", 0, false), "width") + assert.Empty(t, result.String()) +} + +func TestPreviewSafetyForMarkdownAndHTML(t *testing.T) { + result := renderPreview(t, "\x1b]52;c;private-clipboard\x07**Safe**\r\x08\n"+ + "
Name
Cell
\n", 80) + assert.NotContains(t, result, "private-clipboard") + assert.NotContains(t, result, "\r") + assert.NotContains(t, result, "\b") + assert.Contains(t, result, "Safe") + assert.Contains(t, result, "Cell") +} + +func TestSafetyPreservesUnrelatedEscapesAndMarkdownDelimiters(t *testing.T) { + for _, source := range []string{ + "<table> &", + "A & B", + "A &amp; B", + "A & B", + "A © B", + "A A B", + "A A Z", + "A &#27 B", + "A &#x1b Z", + "# Title\n\n**Important** and *emphasis*.", + "中文、日本語、한글 😀", + "https://example.com/doc?page=2#section", + "https://example.com/doc?", + "[Document](https://example.com/doc?page=2#section)", + } { + t.Run(source, func(t *testing.T) { + assert.Equal(t, source, safeText(source)) + }) + } +} + +func TestSafetyNormalizesNewlinesAndRemovesControls(t *testing.T) { + for _, sample := range []struct { + name, source, expected string + }{ + {"newlines-and-tabs", "First\r\n\tSecond\nThird", "First\n\tSecond\nThird"}, + {"raw-controls", "A\x00\x08\rB", "AB"}, + {"ansi-color", "\x1b[31mRed\x1b[0m", "Red"}, + {"decimal-entity", "AB", "AB"}, + {"hex-entity", "AB", "AB"}, + {"decimal-entity-no-semicolon", "AB", "AB"}, + {"hex-entity-no-semicolon", "AZ", "AZ"}, + {"decimal-entity-at-end", "A", "A"}, + {"hex-entity-at-end", "A", "A"}, + {"hex-entity-uppercase", "AZ", "AZ"}, + {"hex-entity-longest-match", "Aƻ", "Aƻ"}, + {"decimal-entity-longest-match", "AĎB", "AĎB"}, + {"encoded-newline-and-tab", "A B", "A B"}, + {"bidi-controls", "中文\u202eABC\u202c\u2066日本語\u2069", "中文ABC日本語"}, + {"encoded-bidi", "A‮B‬", "AB"}, + {"encoded-bidi-no-semicolon", "A‮Z‬", "AZ"}, + } { + t.Run(sample.name, func(t *testing.T) { + actual := safeText(sample.source) + assert.Equal(t, sample.expected, actual) + assert.Equal(t, actual, safeText(actual), "cleaning is stable across repeated calls") + }) + } +} + +func TestPreviewFiltersSemicolonlessControlsBeforeRendering(t *testing.T) { + t.Setenv("NO_COLOR", "") + for _, encoded := range []string{"", "", "", "‮", "‮"} { + for _, sample := range []struct { + name, template string + width int + }{ + {"markdown", "Before %s[31mAfter\n", 120}, + {"metadata", "---\nmimeType: application/pdf\nnote: %s[31mAfter\n---\n", 120}, + {"comment", "\n", 120}, + {"table", "" + + "
%s[31mCaption
Name
Before %s[31mAfter
\n", 120}, + {"cards", "
Name
%s[31m" + + "RECORD0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ
\n", 20}, + {"fallback", "" + + "
Name
Before %s[31mAfter
\n", 120}, + } { + for _, color := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/%s/color=%t", encoded, sample.name, color), func(t *testing.T) { + source := strings.ReplaceAll(sample.template, "%s", encoded) + baseline := strings.ReplaceAll(sample.template, "%s", "") + var actual, expected bytes.Buffer + require.NoError(t, Render(t.Context(), &actual, source, sample.width, color)) + require.NoError(t, Render(t.Context(), &expected, baseline, sample.width, color)) + assert.Equal(t, expected.String(), actual.String(), + "encoded controls must not introduce bytes beyond the renderer's own styling") + }) + } + } + } +} + +func TestPrettyDecodesEntitiesOnce(t *testing.T) { + for _, sample := range []struct { + source, expected string + }{ + {"A & B\n", "A & B"}, + {"A &amp; B\n", "A & B"}, + {"<table>\n", ""}, + {"
Text
A &amp; B
\n", "A & B"}, + } { + result := renderPreview(t, sample.source, 120) + assert.Contains(t, result, sample.expected) + assert.NotContains(t, result, "HTML table not rendered:") + } +} + +func TestPrettyPreservesOrdinaryURLComponents(t *testing.T) { + t.Setenv("NO_COLOR", "") + const destination = "https://example.com/doc?page=2&view=full#section" + for _, sample := range []struct { + name, source, expected, notice string + width int + }{ + {"plain", destination + "\n", destination, "", 200}, + {"link", "[Document](" + destination + ")\n", destination, "", 200}, + {"encoded", "https://example.com/doc?page=2&view=full#section\n", destination, "", 200}, + {"empty-query", "https://example.com/doc?\n", "https://example.com/doc?", "", 200}, + {"table", "
URL
" + destination + "
\n", + destination, "", 200}, + {"cards", "
URL
" + destination + "
\n", + destination, "Table exceeds terminal width", 20}, + {"fallback", "
URL
" + destination + "
\n", + destination, "HTML table not rendered", 200}, + } { + t.Run(sample.name, func(t *testing.T) { + for _, color := range []bool{false, true} { + var result bytes.Buffer + require.NoError(t, Render(t.Context(), &result, sample.source, sample.width, color)) + plain := ansi.Strip(result.String()) + assert.Contains(t, plain, sample.expected) + if sample.notice != "" { + assert.Contains(t, plain, sample.notice) + } + if !color { + assert.NotContains(t, result.String(), "\x1b") + } + } + }) + } +} diff --git a/cli/azd/extensions/azure.ai.content-understanding/internal/preview/safety.go b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/safety.go new file mode 100644 index 00000000000..330177b5350 --- /dev/null +++ b/cli/azd/extensions/azure.ai.content-understanding/internal/preview/safety.go @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package preview + +import ( + "regexp" + "strings" + "unicode" + + "github.com/charmbracelet/x/ansi" + "golang.org/x/net/html" +) + +var entity = regexp.MustCompile(`&(?:#[0-9]+|#[xX][0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);?`) + +func safeText(source string) string { + clean := func(value string) string { + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) && r != '\n' && r != '\t' || unicode.Is(unicode.Bidi_Control, r) { + return -1 + } + return r + }, ansi.Strip(value)) + } + source = clean(strings.ReplaceAll(source, "\r\n", "\n")) + // Encoded terminal controls must not become active when Markdown/HTML decodes them. + source = entity.ReplaceAllStringFunc(source, func(value string) string { + decoded := html.UnescapeString(value) + if clean(decoded) != decoded { + return clean(decoded) + } + return value + }) + + return source +} diff --git a/cli/azd/extensions/azure.ai.content-understanding/version.txt b/cli/azd/extensions/azure.ai.content-understanding/version.txt index 9ff8406fee4..15b416cc5ea 100644 --- a/cli/azd/extensions/azure.ai.content-understanding/version.txt +++ b/cli/azd/extensions/azure.ai.content-understanding/version.txt @@ -1 +1 @@ -0.1.1-preview +0.1.2-preview