import pytest
from click.testing import CliRunner
from unittest.mock import patch, MagicMock
import pytest_httpbin

from scrapling.parser import Selector
from scrapling import __version__
from scrapling.cli import main, shell, mcp, get, post, put, delete, fetch, stealthy_fetch

@pytest_httpbin.use_class_based_httpbin
def configure_selector_mock():
    """Helper function to create a properly configured Selector mock"""
    mock_response = MagicMock(spec=Selector)
    mock_response.body = "Test content"
    mock_response.html_content = "Test content"
    mock_response.encoding = "utf-8"
    mock_response.get_all_text.return_value = "Test content"
    mock_response.css.return_value = [mock_response]
    return mock_response

class TestCLI:
    """Test CLI functionality"""

    @pytest.fixture
    def html_url(self, httpbin):
        return f"{httpbin.url}/html"

    @pytest.fixture
    def runner(self):
        return CliRunner()

    def test_version_flag(self, runner):
        """Test that the --version flag prints the Scrapling version and exits"""
        result = runner.invoke(main, ["--version"])
        assert result.exit_code == 0
        assert result.output.strip() == f"Scrapling, version {__version__}"

    def test_shell_command(self, runner):
        """Test shell command"""
        with patch("scrapling.core.shell.CustomShell") as mock_shell:
            mock_instance = MagicMock()
            mock_shell.return_value = mock_instance

            result = runner.invoke(shell)
            assert result.exit_code == 0
            mock_instance.start.assert_called_once()

    def test_mcp_command(self, runner):
        """Test MCP command"""
        with patch("scrapling.core.ai.ScraplingMCPServer") as mock_server:
            mock_instance = MagicMock()
            mock_server.return_value = mock_instance

            result = runner.invoke(mcp)
            assert result.exit_code == 0
            mock_server.assert_called_once_with(executable_path=None)
            mock_instance.serve.assert_called_once_with(False, "0.0.0.0", 8000)

    def test_mcp_command_with_executable_path(self, runner):
        """Test MCP command with a custom browser executable"""
        with patch("scrapling.core.ai.ScraplingMCPServer") as mock_server:
            mock_instance = MagicMock()
            mock_server.return_value = mock_instance

            result = runner.invoke(mcp, ["--executable-path", "/opt/custom-chromium"])
            assert result.exit_code == 0
            mock_server.assert_called_once_with(executable_path="/opt/custom-chromium")
            mock_instance.serve.assert_called_once_with(False, "0.0.0.0", 8000)

    def test_extract_get_command(self, runner, tmp_path, html_url):
        """Test extract `get` command"""
        output_file = tmp_path / "output.md"

        with patch("scrapling.fetchers.Fetcher.get") as mock_get:
            mock_response = configure_selector_mock()
            mock_response.status = 200
            mock_get.return_value = mock_response

            result = runner.invoke(get, [html_url, str(output_file)])
            assert result.exit_code == 0

        # Test with various options
        with patch("scrapling.fetchers.Fetcher.get") as mock_get:
            mock_get.return_value = mock_response

            result = runner.invoke(
                get,
                [
                    html_url,
                    str(output_file),
                    "-H",
                    "User-Agent: Test",
                    "--cookies",
                    "session=abc123",
                    "--timeout",
                    "60",
                    "--proxy",
                    "http://proxy:8080",
                    "-s",
                    ".content",
                    "-p",
                    "page=1",
                ],
            )
            assert result.exit_code == 0

    def test_extract_post_command(self, runner, tmp_path, html_url):
        """Test extract `post` command"""
        output_file = tmp_path / "output.html"

        with patch("scrapling.fetchers.Fetcher.post") as mock_post:
            mock_response = configure_selector_mock()
            mock_post.return_value = mock_response

            result = runner.invoke(post, [html_url, str(output_file), "-d", "key=value", "-j", '{"data": "test"}'])
            assert result.exit_code == 0

    def test_extract_put_command(self, runner, tmp_path, html_url):
        """Test extract `put` command"""
        output_file = tmp_path / "output.html"

        with patch("scrapling.fetchers.Fetcher.put") as mock_put:
            mock_response = configure_selector_mock()
            mock_put.return_value = mock_response

            result = runner.invoke(put, [html_url, str(output_file), "-d", "key=value", "-j", '{"data": "test"}'])
            assert result.exit_code == 0

    def test_extract_delete_command(self, runner, tmp_path, html_url):
        """Test extract `delete` command"""
        output_file = tmp_path / "output.html"

        with patch("scrapling.fetchers.Fetcher.delete") as mock_delete:
            mock_response = configure_selector_mock()
            mock_delete.return_value = mock_response

            result = runner.invoke(delete, [html_url, str(output_file)])
            assert result.exit_code == 0

    def test_extract_fetch_command(self, runner, tmp_path, html_url):
        """Test extract fetch command"""
        output_file = tmp_path / "output.txt"

        with patch("scrapling.fetchers.DynamicFetcher.fetch") as mock_fetch:
            mock_response = configure_selector_mock()
            mock_fetch.return_value = mock_response

            result = runner.invoke(fetch, [html_url, str(output_file), "--headless", "--timeout", "60000"])
            assert result.exit_code == 0

    def test_extract_stealthy_fetch_command(self, runner, tmp_path, html_url):
        """Test extract fetch command"""
        output_file = tmp_path / "output.md"

        with patch("scrapling.fetchers.StealthyFetcher.fetch") as mock_fetch:
            mock_response = configure_selector_mock()
            mock_fetch.return_value = mock_response

            result = runner.invoke(
                stealthy_fetch,
                [html_url, str(output_file), "--headless", "--css-selector", "body", "--timeout", "60000"],
            )
            assert result.exit_code == 0

    def test_extract_fetch_with_executable_path(self, runner, tmp_path, html_url, monkeypatch):
        """Test that --executable-path is passed through to DynamicFetcher and wins over the environment variable"""
        output_file = tmp_path / "output.html"
        monkeypatch.setenv("SCRAPLING_EXECUTABLE_PATH", "/opt/env-chromium")

        with patch("scrapling.fetchers.DynamicFetcher.fetch") as mock_fetch:
            mock_fetch.return_value = configure_selector_mock()

            result = runner.invoke(fetch, [html_url, str(output_file), "--executable-path", "/opt/custom-chromium"])
            assert result.exit_code == 0
            assert mock_fetch.call_args.kwargs["executable_path"] == "/opt/custom-chromium"

    def test_extract_fetch_executable_path_env_fallback(self, runner, tmp_path, html_url, monkeypatch):
        """Test that SCRAPLING_EXECUTABLE_PATH is used when --executable-path is not passed"""
        output_file = tmp_path / "output.html"
        monkeypatch.setenv("SCRAPLING_EXECUTABLE_PATH", "/opt/env-chromium")

        with patch("scrapling.fetchers.DynamicFetcher.fetch") as mock_fetch:
            mock_fetch.return_value = configure_selector_mock()

            result = runner.invoke(fetch, [html_url, str(output_file)])
            assert result.exit_code == 0
            assert mock_fetch.call_args.kwargs["executable_path"] == "/opt/env-chromium"

    def test_extract_fetch_without_executable_path(self, runner, tmp_path, html_url, monkeypatch):
        """Test that executable_path is not passed to the fetcher when neither source is set"""
        output_file = tmp_path / "output.html"
        monkeypatch.delenv("SCRAPLING_EXECUTABLE_PATH", raising=False)

        with patch("scrapling.fetchers.DynamicFetcher.fetch") as mock_fetch:
            mock_fetch.return_value = configure_selector_mock()

            result = runner.invoke(fetch, [html_url, str(output_file)])
            assert result.exit_code == 0
            assert "executable_path" not in mock_fetch.call_args.kwargs

    def test_extract_stealthy_fetch_with_executable_path(self, runner, tmp_path, html_url, monkeypatch):
        """Test that --executable-path and the environment fallback work for stealthy_fetch too"""
        output_file = tmp_path / "output.html"
        monkeypatch.delenv("SCRAPLING_EXECUTABLE_PATH", raising=False)

        with patch("scrapling.fetchers.StealthyFetcher.fetch") as mock_fetch:
            mock_fetch.return_value = configure_selector_mock()

            result = runner.invoke(
                stealthy_fetch, [html_url, str(output_file), "--executable-path", "/opt/custom-chromium"]
            )
            assert result.exit_code == 0
            assert mock_fetch.call_args.kwargs["executable_path"] == "/opt/custom-chromium"

        monkeypatch.setenv("SCRAPLING_EXECUTABLE_PATH", "/opt/env-chromium")
        with patch("scrapling.fetchers.StealthyFetcher.fetch") as mock_fetch:
            mock_fetch.return_value = configure_selector_mock()

            result = runner.invoke(stealthy_fetch, [html_url, str(output_file)])
            assert result.exit_code == 0
            assert mock_fetch.call_args.kwargs["executable_path"] == "/opt/env-chromium"

    def test_invalid_arguments(self, runner, html_url):
        """Test invalid arguments handling"""
        # Missing required arguments
        result = runner.invoke(get)
        assert result.exit_code != 0

        _ = runner.invoke(get, [html_url, "output.invalid"])
        # Should handle the error gracefully

    def test_impersonate_comma_separated(self, runner, tmp_path, html_url):
        """Test that comma-separated impersonate values are parsed correctly"""
        output_file = tmp_path / "output.md"

        with patch("scrapling.fetchers.Fetcher.get") as mock_get:
            mock_response = configure_selector_mock()
            mock_response.status = 200
            mock_get.return_value = mock_response

            result = runner.invoke(get, [html_url, str(output_file), "--impersonate", "chrome,firefox,safari"])
            assert result.exit_code == 0

            # Verify that the impersonate argument was converted to a list
            call_kwargs = mock_get.call_args[1]
            assert isinstance(call_kwargs["impersonate"], list)
            assert call_kwargs["impersonate"] == ["chrome", "firefox", "safari"]

    def test_impersonate_single_browser(self, runner, tmp_path, html_url):
        """Test that single impersonate value remains as string"""
        output_file = tmp_path / "output.md"

        with patch("scrapling.fetchers.Fetcher.get") as mock_get:
            mock_response = configure_selector_mock()
            mock_response.status = 200
            mock_get.return_value = mock_response

            result = runner.invoke(get, [html_url, str(output_file), "--impersonate", "chrome"])
            assert result.exit_code == 0

            # Verify that the impersonate argument remains a string
            call_kwargs = mock_get.call_args[1]
            assert isinstance(call_kwargs["impersonate"], str)
            assert call_kwargs["impersonate"] == "chrome"


#!/usr/bin/env node
// Version-consistency guard. Ponytail declares its version in seven files across
// five host ecosystems, and every release bumps all of them by hand.
//
// tests/gemini-extension.test.js already checks the four plugin manifests agree
// with each other, but that can't catch the failure mode that shipped in v4.8.0:
// every manifest stayed stale at 4.7.0 *together* while the release moved on, so
// they "agreed" and the test passed (#260, #262). It also ignores the two
// package.json files. This check closes both gaps:
//   1. every version-bearing file must share one pinned X.Y.Z version, and
//   2. on a release-tag CI run, that shared version must equal the tag.

const fs = require('fs');
const path = require('path');

const root = path.join(__dirname, '..');
const PINNED_SEMVER = /^\d+\.\d+\.\d+$/;

// Every file that declares the project version, and who reads it. Add new host
// manifests here so a future ecosystem can't drift unnoticed.
const VERSION_FILES = [
  '.claude-plugin/plugin.json',  // Claude Code plugin — what users install
  '.codex-plugin/plugin.json',   // Codex plugin
  '.devin-plugin/plugin.json',   // Devin CLI plugin
  '.github/plugin/plugin.json',  // Copilot plugin
  '.qoder-plugin/plugin.json',   // Qoder plugin
  'gemini-extension.json',       // Gemini CLI extension
  'package.json',                // pi-package / repo root
  'ponytail-mcp/package.json',   // MCP server (private, internal-only)
];

function readVersion(relPath) {
  try {
    // Strip a UTF-8 BOM some Windows editors prepend (breaks JSON.parse).
    const raw = fs.readFileSync(path.join(root, relPath), 'utf8').replace(/^\uFEFF/, '');
    return JSON.parse(raw).version;
  } catch (e) {
    throw new Error(`${relPath}: ${e.message}`);
  }
}

let failed = false;
const versions = VERSION_FILES.map((relPath) => {
  const version = readVersion(relPath);
  if (typeof version !== 'string' || !PINNED_SEMVER.test(version)) {
    console.error(`${relPath}: version must be a pinned X.Y.Z semver, got ${JSON.stringify(version)}`);
    failed = true;
  }
  return [relPath, version];
});

// Every file must declare the same version.
const distinct = [...new Set(versions.map(([, v]) => v))];
if (distinct.length > 1) {
  console.error('Version mismatch — every manifest must share one version:');
  for (const [relPath, version] of versions) console.error(`  ${version}\t${relPath}`);
  failed = true;
}
const shared = distinct.length === 1 ? distinct[0] : null;

// On a release-tag push CI sets GITHUB_REF_TYPE=tag and GITHUB_REF_NAME=vX.Y.Z.
// The shared version must equal the tag — this catches tagging a release whose
// version files were never bumped, which mutual agreement alone cannot.
if (shared && process.env.GITHUB_REF_TYPE === 'tag') {
  const tag = process.env.GITHUB_REF_NAME || '';
  const tagVersion = tag.replace(/^v/, '');
  if (PINNED_SEMVER.test(tagVersion) && tagVersion !== shared) {
    console.error(`release tag ${tag} does not match version ${shared}; bump the version files before tagging`);
    failed = true;
  }
}

if (failed) {
  console.error('Align the version fields (see issue #260) so every manifest shares one version.');
  process.exit(1);
}

console.log(`All ${VERSION_FILES.length} version files pinned at ${shared}.`);


import pytest
from unittest.mock import patch, MagicMock, AsyncMock
from curl_cffi.curl import CurlError

from scrapling.engines.static import _ASyncSessionLogic as AsyncFetcherSession, AsyncFetcherClient
from scrapling.engines.toolbelt import ProxyRotator

class TestFetcherSession:
    """Test FetcherSession functionality"""

    def test_async_fetcher_client_creation(self):
        """Test AsyncFetcherClient creation"""
        client = AsyncFetcherClient()

        # Should not have context manager methods
        assert client.__aenter__ is None
        assert client.__aexit__ is None

    @pytest.mark.asyncio
    async def test_session_level_proxy_is_applied(self):
        """Session-level proxy must reach the request, not be silently dropped (#295)"""
        proxy = "http://10.255.255.1:9999"

        async with AsyncFetcherSession(proxy=proxy) as session:
            with (
                patch.object(session._async_curl_session, "request", new=AsyncMock()) as mocked_request,
                patch("scrapling.engines.static.ResponseFactory.from_http_request", return_value=MagicMock()),
            ):
                await session.get("http://example.com")

            assert mocked_request.call_args.kwargs["proxy"] == proxy

    @pytest.mark.asyncio
    async def test_per_request_proxy_overrides_session_proxy(self):
        """A per-request proxy must take precedence over the session-level proxy"""
        request_proxy = "http://10.255.255.2:9999"

        async with AsyncFetcherSession(proxy="http://10.255.255.1:9999") as session:
            with (
                patch.object(session._async_curl_session, "request", new=AsyncMock()) as mocked_request,
                patch("scrapling.engines.static.ResponseFactory.from_http_request", return_value=MagicMock()),
            ):
                await session.get("http://example.com", proxy=request_proxy)

            assert mocked_request.call_args.kwargs["proxy"] == request_proxy

    @pytest.mark.asyncio
    async def test_proxy_rotates_per_retry_attempt(self):
        """With a rotator, every retry attempt must pull a fresh proxy"""
        rotator = ProxyRotator(["http://p1:8080", "http://p2:8080"])

        async with AsyncFetcherSession(proxy_rotator=rotator, retries=2, retry_delay=0) as session:
            with (
                patch.object(session._async_curl_session, "request", new=AsyncMock()) as mocked_request,
                patch("scrapling.engines.static.ResponseFactory.from_http_request", return_value=MagicMock()),
            ):
                mocked_request.side_effect = [CurlError("transient"), MagicMock()]
                await session.get("http://example.com")

            proxies_used = [call.kwargs["proxy"] for call in mocked_request.call_args_list]
            assert proxies_used == ["http://p1:8080", "http://p2:8080"]


// Code generated by templ - DO NOT EDIT.

// templ: version: v0.3.1020
package proofofwork

//lint:file-ignore SA4006 This context is only used if a nested component is present.

import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"

import (
	"github.com/TecharoHQ/anubis"
	"github.com/TecharoHQ/anubis/lib/localization"
)

func page(localizer *localization.SimpleLocalizer) templ.Component {
	return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
		templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
		if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
			return templ_7745c5c3_CtxErr
		}
		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
		if !templ_7745c5c3_IsBuffer {
			defer func() {
				templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
				if templ_7745c5c3_Err == nil {
					templ_7745c5c3_Err = templ_7745c5c3_BufErr
				}
			}()
		}
		ctx = templ.InitializeContext(ctx)
		templ_7745c5c3_Var1 := templ.GetChildren(ctx)
		if templ_7745c5c3_Var1 == nil {
			templ_7745c5c3_Var1 = templ.NopComponent
		}
		ctx = templ.ClearChildren(ctx)
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
<img id=\"image\" style=\"width:100%;max-width:256px;\" src=\"")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var2 string
		templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version)
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 10, Col: 165}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"> <img style=\"display:none;\" style=\"width:100%;max-width:256px;\" src=\"")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var3 string
		templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=" + anubis.Version)
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 11, Col: 174}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">
")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var4 string
		templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("loading"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 12, Col: 41}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		if anubis.UseSimplifiedExplanation {
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
")
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			var templ_7745c5c3_Var6 string
			templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("simplified_explanation"))
			if templ_7745c5c3_Err != nil {
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 20, Col: 44}
			}
			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
")
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
		} else {
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
")
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			var templ_7745c5c3_Var7 string
			templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("ai_companies_explanation"))
			if templ_7745c5c3_Err != nil {
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 24, Col: 46}
			}
			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

")
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			var templ_7745c5c3_Var8 string
			templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("anubis_compromise"))
			if templ_7745c5c3_Err != nil {
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 27, Col: 39}
			}
			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

")
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			var templ_7745c5c3_Var9 string
			templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("hack_purpose"))
			if templ_7745c5c3_Err != nil {
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 30, Col: 34}
			}
			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

")
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			var templ_7745c5c3_Var10 string
			templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("jshelter_note"))
			if templ_7745c5c3_Err != nil {
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 33, Col: 35}
			}
			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
")
			if templ_7745c5c3_Err != nil {
				return templ_7745c5c3_Err
			}
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var11 string
		templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("javascript_required"))
		if templ_7745c5c3_Err != nil {
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 39, Col: 40}
		}
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		return nil
	})
}

var _ = templruntime.GeneratedTemplate


"""Pure URL discovery primitive"""

import re
from urllib.parse import urlsplit

from w3lib.html import strip_html5_whitespace
from w3lib.url import canonicalize_url, safe_url_string

from scrapling.core._types import (
    TYPE_CHECKING,
    Iterable,
    Callable,
    List,
    Optional,
    Pattern,
    Set,
    Tuple,
    Union,
    Any,
)
from scrapling.core.utils import log

if TYPE_CHECKING:
    from scrapling.engines.toolbelt.custom import Response

__all__ = ["LinkExtractor"]
valid_schemas = {"http", "https", "file"}

IGNORED_EXTENSIONS = {
    # archives
    "7z",
    "7zip",
    "bz2",
    "rar",
    "tar",
    "tar.gz",
    "xz",
    "zip",
    # images
    "mng",
    "pct",
    "bmp",
    "gif",
    "jpg",
    "jpeg",
    "png",
    "pst",
    "psp",
    "tif",
    "tiff",
    "ai",
    "drw",
    "dxf",
    "eps",
    "ps",
    "svg",
    "cdr",
    "ico",
    "webp",
    # audio
    "mp3",
    "wma",
    "ogg",
    "wav",
    "ra",
    "aac",
    "mid",
    "au",
    "aiff",
    # video
    "3gp",
    "asf",
    "asx",
    "avi",
    "mov",
    "mp4",
    "mpg",
    "qt",
    "rm",
    "swf",
    "wmv",
    "m4a",
    "m4v",
    "flv",
    "webm",
    # office suites
    "xls",
    "xlsm",
    "xlsx",
    "xltm",
    "xltx",
    "potm",
    "potx",
    "ppt",
    "pptm",
    "pptx",
    "pps",
    "doc",
    "docb",
    "docm",
    "docx",
    "dotm",
    "dotx",
    "odt",
    "ods",
    "odg",
    "odp",
    # other
    "css",
    "pdf",
    "exe",
    "bin",
    "rss",
    "dmg",
    "iso",
    "apk",
    "jar",
    "sh",
    "rb",
    "js",
    "hta",
    "bat",
    "cpl",
    "msi",
    "msp",
    "py",
}

PatternInput = Iterable[Union[str, Pattern[str]]]
StrOrIterable = Union[str, Iterable[str]]

def _to_str_tuple(value: StrOrIterable) -> Tuple[str, ...]:
    if not value:
        return ()
    if isinstance(value, str):
        return (value,)
    return tuple(value)

def _compile_patterns(patterns: Union[str, Pattern[str], PatternInput, None]) -> Tuple[Pattern[str], ...]:
    if not patterns:
        return ()
    if isinstance(patterns, (str, re.Pattern)):
        patterns = (patterns,)
    return tuple(p if isinstance(p, re.Pattern) else re.compile(p) for p in patterns)

def _url_extension(url: str) -> str:
    extensions = _url_extensions(url)
    return extensions[0] if extensions else ""

def _url_extensions(url: str) -> Tuple[str, ...]:
    path = urlsplit(url).path
    _, _, last = path.rpartition("/")
    if "." not in last:
        return ()
    parts = last.lower().split(".")
    return tuple(".".join(parts[i:]) for i in range(1, len(parts)) if parts[i])

def _filler(x):
    return x

class LinkExtractor:
    """Extracts and filters URLs from a `Response` (or a single URL via `matches`).

    All matching is regex-based; allow/deny patterns can be plain strings (compiled
    with `re.compile`) or pre-compiled `re.Pattern` objects, individually or as an
    iterable.

    :param allow: Regex pattern(s) URLs must match to be kept. String, compiled `re.Pattern`,
        or an iterable of either. Empty means match all.
    :param deny: Regex pattern(s) URLs must NOT match. Takes precedence over `allow`.
    :param allow_domains: Domain(s) to keep. Matches the exact host or any subdomain
        (e.g. `"example.com"` matches `"api.example.com"`). String or iterable.
    :param deny_domains: Domain(s) to exclude. Same matching rules as `allow_domains`.
    :param restrict_css: CSS selectors to scope DOM extraction to. Empty means whole page.
    :param restrict_xpath: XPath selectors to scope DOM extraction to. Empty means whole page.
    :param tags: Element tags to look for links in. Default ("a", "area").
    :param attrs: Attributes on those tags to read URLs from. Default ("href",).
    :param canonicalize: Canonicalize URLs (sort query params, normalize path). Default True.
    :param strip: Strip whitespace from extracted URLs. Default True.
    :param keep_fragment: Preserve the URL fragment when canonicalizing. Default False.
    :param deny_extensions: File extensions to drop. Default `IGNORED_EXTENSIONS`.
    :param process: A function to do a process on the values extracted before using them. Return None to drop any value.
    """

    def __init__(
        self,
        allow: Union[str, Pattern[str], PatternInput] = (),
        deny: Union[str, Pattern[str], PatternInput] = (),
        allow_domains: StrOrIterable = (),
        deny_domains: StrOrIterable = (),
        restrict_css: StrOrIterable = (),
        restrict_xpath: StrOrIterable = (),
        tags: Iterable[str] = ("a", "area"),
        attrs: Iterable[str] = ("href",),
        canonicalize: bool = True,
        strip: bool = True,
        keep_fragment: bool = False,
        deny_extensions: Optional[Iterable[str]] = None,
        process: Callable[[Any], Any] | None = None,
    ) -> None:
        self.allow: Tuple[Pattern[str], ...] = _compile_patterns(allow)
        self.deny: Tuple[Pattern[str], ...] = _compile_patterns(deny)
        self.allow_domains: Tuple[str, ...] = tuple(d.lower() for d in _to_str_tuple(allow_domains))
        self.deny_domains: Tuple[str, ...] = tuple(d.lower() for d in _to_str_tuple(deny_domains))
        self.restrict_css: Tuple[str, ...] = _to_str_tuple(restrict_css)
        self.restrict_xpath: Tuple[str, ...] = _to_str_tuple(restrict_xpath)
        self.tags: Tuple[str, ...] = tuple(tags)
        self.attrs: Tuple[str, ...] = tuple(attrs)
        self.canonicalize = canonicalize
        self.strip = strip
        self.keep_fragment = keep_fragment
        self.deny_extensions: Set[str] = set(
            (ext.lower().lstrip(".") for ext in deny_extensions) if deny_extensions is not None else IGNORED_EXTENSIONS
        )
        self.process: Callable[[Any], Any] = process if callable(process) else _filler

    def extract(self, response: "Response") -> List[str]:
        """Return absolute, filtered, deduped URLs from `response`."""
        scopes: List[Any] = []
        if self.restrict_xpath:
            for xp in self.restrict_xpath:
                scopes.extend(response.xpath(xp))
        if self.restrict_css:
            for cs in self.restrict_css:
                scopes.extend(response.css(cs))
        if not scopes:
            scopes = [response]

        out: List[str] = []
        search_selector = "| ".join([f".//{tag}/@{attr}" for tag in self.tags for attr in self.attrs])
        for scope in scopes:
            for url in scope._root.xpath(search_selector):
                if not url:
                    continue
                url = str(url)
                if self.strip:
                    url = strip_html5_whitespace(url)
                    if not url:
                        continue
                url = str(response.urljoin(url))
                url = self.process(url)
                if not url:
                    continue

                if self.canonicalize:
                    url = canonicalize_url(url, keep_fragments=self.keep_fragment)

                try:
                    url = safe_url_string(url, encoding=response.encoding)
                except ValueError:
                    log.debug(f"Skipping the extraction of bad URL {url!r}")
                    continue

                if not self._url_passes(url):
                    continue

                out.append(url)

        # Switching to dict for deduplication instead of Set will keep the insertion order of the links.
        return list(dict.fromkeys(out))

    def matches(self, url: str) -> bool:
        """URL-only filter (no response extraction).

        Applies allow/deny/allow_domains/deny_domains/deny_extensions to a single URL.
        Used by `SitemapSpider` to dispatch sitemap URLs through `CrawlRule`s without
        needing a `Response`.
        """
        if self.canonicalize:
            url = canonicalize_url(url, keep_fragments=self.keep_fragment)
        return self._url_passes(url)

    def _url_passes(self, url: str) -> bool:
        if url.split("://", 1)[0] not in valid_schemas:
            return False

        if self.deny_extensions and any(ext in self.deny_extensions for ext in _url_extensions(url)):
            return False

        if self.allow and not any(p.search(url) for p in self.allow):
            return False
        if self.deny and any(p.search(url) for p in self.deny):
            return False

        if self.allow_domains or self.deny_domains:
            host = (urlsplit(url).hostname or "").lower()
            if self.allow_domains and not any(host == d or host.endswith("." + d) for d in self.allow_domains):
                return False
            if self.deny_domains and any(host == d or host.endswith("." + d) for d in self.deny_domains):
                return False
        return True


from typing import Any, BinaryIO

from ._exiftool import exiftool_metadata
from ._transcribe_audio import transcribe_audio
from .._base_converter import DocumentConverter, DocumentConverterResult
from .._stream_info import StreamInfo
from .._exceptions import MissingDependencyException

ACCEPTED_MIME_TYPE_PREFIXES = [
    "audio/x-wav",
    "audio/mpeg",
    "video/mp4",
]

ACCEPTED_FILE_EXTENSIONS = [
    ".wav",
    ".mp3",
    ".m4a",
    ".mp4",
]

class AudioConverter(DocumentConverter):
    """
    Converts audio files to markdown via extraction of metadata (if `exiftool` is installed), and speech transcription (if `speech_recognition` is installed).
    """

    def accepts(
        self,
        file_stream: BinaryIO,
        stream_info: StreamInfo,
        **kwargs: Any,  # Options to pass to the converter
    ) -> bool:
        mimetype = (stream_info.mimetype or "").lower()
        extension = (stream_info.extension or "").lower()

        if extension in ACCEPTED_FILE_EXTENSIONS:
            return True

        for prefix in ACCEPTED_MIME_TYPE_PREFIXES:
            if mimetype.startswith(prefix):
                return True

        return False

    def convert(
        self,
        file_stream: BinaryIO,
        stream_info: StreamInfo,
        **kwargs: Any,  # Options to pass to the converter
    ) -> DocumentConverterResult:
        md_content = ""

        # Add metadata
        metadata = exiftool_metadata(
            file_stream, exiftool_path=kwargs.get("exiftool_path")
        )
        if metadata:
            for f in [
                "Title",
                "Artist",
                "Author",
                "Band",
                "Album",
                "Genre",
                "Track",
                "DateTimeOriginal",
                "CreateDate",
                # "Duration", -- Wrong values when read from memory
                "NumChannels",
                "SampleRate",
                "AvgBytesPerSec",
                "BitsPerSample",
            ]:
                if f in metadata:
                    md_content += f"{f}: {metadata[f]}\n"

        # Figure out the audio format for transcription
        if stream_info.extension == ".wav" or stream_info.mimetype == "audio/x-wav":
            audio_format = "wav"
        elif stream_info.extension == ".mp3" or stream_info.mimetype == "audio/mpeg":
            audio_format = "mp3"
        elif (
            stream_info.extension in [".mp4", ".m4a"]
            or stream_info.mimetype == "video/mp4"
        ):
            audio_format = "mp4"
        else:
            audio_format = None

        # Transcribe
        if audio_format:
            try:
                transcript = transcribe_audio(file_stream, audio_format=audio_format)
                if transcript:
                    md_content += "\n\n### Audio Transcript:\n" + transcript
            except MissingDependencyException:
                pass

        # Return the result
        return DocumentConverterResult(markdown=md_content.strip())


#!/usr/bin/env node
// caveman-stats — read the active Claude Code session log, print real token
// usage plus an estimated savings figure from the benchmark in benchmarks/.
//
// Run directly:    node hooks/caveman-stats.js
// Inside Claude:   /caveman-stats triggers this via the UserPromptSubmit hook.
// Hook integration passes --session-file <transcript_path> so we always read
// the active session, not whichever JSONL was modified most recently.

const fs = require('fs');
const path = require('path');
const os = require('os');
const { readFlag, appendFlag, readHistory, safeWriteFlag, VALID_MODES, MODE_LOG_BASENAME } = require('./caveman-config');

// Mean per-task savings from benchmarks/results/*.json (avg_savings: 65 across
// 10 tasks, sonnet-4-20250514). Only 'full' has measured data; lite / ultra /
// wenyan modes show no estimate until benchmarked. Add an entry here when a new
// run is committed.
const COMPRESSION = { 'full': 0.65 };

// Approximate Anthropic public output-token pricing, USD per million.
// Match by model id prefix so this stays correct across point releases
// (e.g. claude-sonnet-4-20250514, claude-sonnet-4-7). Update from
// https://www.anthropic.com/pricing if a release changes the tier.
// Most-specific prefixes MUST come first — priceForModel returns the first match.
const MODEL_OUTPUT_PRICE_PER_M = [
  // Legacy Opus 4.0 / 4.1 (pre-4.5) billed at the old $75/M output tier,
  // including the dated ids (e.g. claude-opus-4-20250514).
  ['claude-opus-4-0',    75.00],
  ['claude-opus-4-1',    75.00],
  ['claude-opus-4-2025', 75.00],
  // Opus 4.5–4.8 dropped to $25/M output (rate card held since 4.5).
  ['claude-opus-4',      25.00],
  ['claude-sonnet-4',    15.00],
  ['claude-haiku-4',      5.00],   // Haiku 4.5 = $5/M output
  ['claude-3-5-sonnet',  15.00],
  ['claude-3-5-haiku',    4.00],
  ['claude-3-opus',      75.00],
];

function priceForModel(model) {
  if (!model) return null;
  for (const [prefix, price] of MODEL_OUTPUT_PRICE_PER_M) {
    if (model.startsWith(prefix)) return price;
  }
  return null;
}

function formatUsd(amount) {
  if (amount >= 1) return `$${amount.toFixed(2)}`;
  if (amount >= 0.01) return `$${amount.toFixed(3)}`;
  return `$${amount.toFixed(4)}`;
}

function findRecentSession(claudeDir) {
  const projectsDir = path.join(claudeDir, 'projects');
  let entries;
  try { entries = fs.readdirSync(projectsDir, { withFileTypes: true }); }
  catch { return null; }

  let best = null;
  const stack = entries.map(e => path.join(projectsDir, e.name));
  while (stack.length) {
    const p = stack.pop();
    let st;
    try { st = fs.statSync(p); } catch { continue; }
    if (st.isDirectory()) {
      try {
        for (const child of fs.readdirSync(p)) stack.push(path.join(p, child));
      } catch {}
    } else if (p.endsWith('.jsonl') && (!best || st.mtimeMs > best.mtime)) {
      best = { file: p, mtime: st.mtimeMs };
    }
  }
  return best ? best.file : null;
}

function parseSession(filePath) {
  let raw;
  try { raw = fs.readFileSync(filePath, 'utf8'); }
  catch { return { outputTokens: 0, cacheReadTokens: 0, turns: 0, model: null, messages: [] }; }

  let outputTokens = 0;
  let cacheReadTokens = 0;
  let turns = 0;
  let model = null;
  const messages = []; // per-message {ts, outputTokens} for mode attribution (#601)
  for (const line of raw.split('\n')) {
    if (!line.trim()) continue;
    let entry;
    try { entry = JSON.parse(line); } catch { continue; }
    if (entry.type !== 'assistant' || !entry.message) continue;
    const usage = entry.message.usage;
    if (!usage) continue;
    outputTokens    += usage.output_tokens           || 0;
    cacheReadTokens += usage.cache_read_input_tokens || 0;
    turns++;
    if (!model && entry.message.model) model = entry.message.model;
    const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
    messages.push({
      ts: Number.isFinite(ts) ? ts : null,
      outputTokens: usage.output_tokens || 0,
    });
  }
  return { outputTokens, cacheReadTokens, turns, model, messages };
}

// Detect *.original.md / *.md pairs left behind by caveman-compress. The
// presence of a *.original.md backup means the *.md sibling is a compressed
// memory file — every session start reads the compressed version, so the
// delta is per-session input-token savings (passive). Returns a summary or
// null if nothing was found in the given dirs.
function findCompressedPairs(dirs) {
  const pairs = [];
  for (const dir of dirs) {
    let entries;
    try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
    catch { continue; }
    for (const entry of entries) {
      if (!entry.isFile() || !entry.name.endsWith('.original.md')) continue;
      const base = entry.name.slice(0, -'.original.md'.length);
      const originalPath = path.join(dir, entry.name);
      const compressedPath = path.join(dir, `${base}.md`);
      let oSize, cSize;
      try {
        oSize = fs.statSync(originalPath).size;
        cSize = fs.statSync(compressedPath).size;
      } catch { continue; }
      if (oSize <= cSize) continue;
      pairs.push({ name: base, dir, originalSize: oSize, compressedSize: cSize });
    }
  }
  return pairs;
}

function summarizeCompressed(pairs) {
  if (!pairs || pairs.length === 0) return null;
  const totalOriginal = pairs.reduce((s, p) => s + p.originalSize, 0);
  const totalCompressed = pairs.reduce((s, p) => s + p.compressedSize, 0);
  const bytesSaved = totalOriginal - totalCompressed;
  // English prose runs ~4 chars per token. Label result as approximate so we
  // don't make claims tighter than the method warrants.
  const tokensSaved = Math.round(bytesSaved / 4);
  return { count: pairs.length, bytesSaved, tokensSaved };
}

// ── Per-mode attribution (#601) ─────────────────────────────────────────────
// The whole session's tokens must never be credited to whatever mode the flag
// happens to hold at stats time — a mid-session mode change would inflate the
// estimate (verbose tokens counted as compressed) or zero it (caveman tokens
// counted as uncompressed). The mode tracker + SessionStart hook append
// {ts, mode, prev} rows to .caveman-mode-log.jsonl on every actual transition;
// stats joins those timestamps against the session JSONL message timestamps.

// Read + validate the transition log. Returns rows sorted by ts.
function readModeLog(logPath) {
  const rows = [];
  for (const line of readHistory(logPath)) {
    let e;
    try { e = JSON.parse(line); } catch { continue; }
    if (!e || typeof e !== 'object' || !Number.isFinite(e.ts)) continue;
    const norm = (v) => (v == null ? null : (VALID_MODES.includes(String(v)) ? String(v) : undefined));
    const mode = norm(e.mode);
    const prev = norm(e.prev);
    if (mode === undefined || prev === undefined) continue; // reject non-whitelisted values
    rows.push({ ts: e.ts, mode, prev });
  }
  rows.sort((a, b) => a.ts - b.ts);
  return rows;
}

// Attribute each message's output tokens to the mode active when it was
// generated. Sources, most to least exact:
//   'log'           — the transition log covers the message (rows at/before its
//                     ts, or the first row's `prev` for the pre-inception span).
//   'flag-mtime'    — no log rows, but the flag was written mid-session: tokens
//                     from the write onward belong to the current mode; earlier
//                     tokens have UNKNOWN mode and are excluded, never guessed
//                     (no-fake-savings). Messages without timestamps are also
//                     unknown in this case.
//   'whole-session' — no log and no evidence of a mid-session change: the
//                     current mode covers the whole session (correct when the
//                     mode never changed; pre-#601 behavior).
// Returns { byMode: {modeKey: tokens}, unknownTokens, basis } where modeKey is
// a mode string or 'none' (caveman inactive).
function attributeByMode({ messages, modeLog, mode, flagMtimeMs, outputTokens }) {
  const currentKey = mode || 'none';
  const msgs = messages || [];
  let firstTs = null;
  for (const m of msgs) {
    if (m.ts != null && (firstTs === null || m.ts < firstTs)) firstTs = m.ts;
  }

  let events = modeLog || [];
  let basis = 'log';
  let prefixMode; // mode for messages before the first event (undefined = unknown)
  if (events.length === 0) {
    if (flagMtimeMs != null && firstTs != null && flagMtimeMs > firstTs) {
      // Flag written mid-session with no transition log: only the span from
      // the write onward is attributable. The write may have been a
      // reaffirmation of the same mode, but assuming so would guess savings
      // into existence — exclude the prefix instead.
      events = [{ ts: flagMtimeMs, mode: mode || null }];
      basis = 'flag-mtime';
      prefixMode = undefined;
    } else {
      return { byMode: { [currentKey]: outputTokens || 0 }, unknownTokens: 0, basis: 'whole-session' };
    }
  } else {
    // Every transition since log inception is recorded, so the span before
    // the first row ran under that row's `prev` mode.
    prefixMode = events[0].prev;
  }

  const byMode = {};
  let unknownTokens = 0;
  const add = (key, tokens) => { byMode[key] = (byMode[key] || 0) + tokens; };
  for (const m of msgs) {
    if (m.ts == null) { unknownTokens += m.outputTokens; continue; }
    let active;
    for (const ev of events) {
      if (ev.ts <= m.ts) active = ev;
      else break;
    }
    if (active !== undefined) add(active.mode || 'none', m.outputTokens);
    else if (prefixMode !== undefined) add(prefixMode || 'none', m.outputTokens);
    else unknownTokens += m.outputTokens;
  }
  return { byMode, unknownTokens, basis };
}

// Attribution shape for callers without a session log to join against
// (kept for formatStats/formatShare backward compatibility in tests).
function wholeSessionAttribution(mode, outputTokens) {
  return { byMode: { [mode || 'none']: outputTokens || 0 }, unknownTokens: 0, basis: 'whole-session' };
}

// Compute the savings figures we want to log/share for one session snapshot.
// Sums per-mode: only spans whose mode has benchmark data earn an estimate;
// unknown spans earn nothing.
function deriveSavings({ byMode, model }) {
  let estSavedTokens = 0;
  for (const [key, tokens] of Object.entries(byMode || {})) {
    const ratio = COMPRESSION[key];
    if (ratio == null || tokens <= 0) continue;
    estSavedTokens += Math.round(tokens / (1 - ratio)) - tokens;
  }
  const price = priceForModel(model);
  const estSavedUsd = price !== null ? (estSavedTokens / 1_000_000) * price : 0;
  return { estSavedTokens, estSavedUsd };
}

// Parse "7d", "12h" etc. to milliseconds. Returns null on invalid input.
function parseDuration(spec) {
  if (!spec) return null;
  const m = /^(\d+)([dh])$/.exec(spec.trim());
  if (!m) return null;
  const n = parseInt(m[1], 10);
  return m[2] === 'd' ? n * 86_400_000 : n * 3_600_000;
}

// Aggregate history into latest-per-session totals, optionally filtered to a
// time window. Returns { sessions, outputTokens, estSavedTokens, estSavedUsd }.
function aggregateHistory(historyPath, sinceMs) {
  const lines = readHistory(historyPath);
  const cutoff = sinceMs ? Date.now() - sinceMs : null;
  const latestPerSession = new Map();
  for (const line of lines) {
    let entry;
    try { entry = JSON.parse(line); } catch { continue; }
    if (!entry || typeof entry !== 'object') continue;
    if (cutoff !== null && (entry.ts || 0) < cutoff) continue;
    const id = entry.session_id || '_';
    const prev = latestPerSession.get(id);
    if (!prev || (entry.ts || 0) >= (prev.ts || 0)) latestPerSession.set(id, entry);
  }
  let outputTokens = 0, estSavedTokens = 0, estSavedUsd = 0;
  for (const e of latestPerSession.values()) {
    outputTokens   += e.output_tokens     || 0;
    estSavedTokens += e.est_saved_tokens  || 0;
    estSavedUsd    += e.est_saved_usd     || 0;
  }
  return { sessions: latestPerSession.size, outputTokens, estSavedTokens, estSavedUsd };
}

// Output-reduction share: saved / (saved + used) = the fraction of the
// would-be OUTPUT tokens that caveman avoided. That is the only ratio we can
// honestly compute from output counts alone. It is NOT a share of session or
// limit usage — input + cache tokens dominate agentic sessions, count against
// Pro/Max limits, and are not reduced by caveman, so real limit relief is far
// smaller (docs/HONEST-NUMBERS.md: session-level totals land ~14–21%, below
// zero on terse workloads). Never label this "usage" or "budget". Returns a
// rounded percent, or null when there is nothing measured to divide.
function outputReductionPct(savedTokens, usedTokens) {
  if (!Number.isFinite(savedTokens) || !Number.isFinite(usedTokens)) return null;
  if (savedTokens <= 0 || usedTokens < 0) return null;
  const total = savedTokens + usedTokens;
  if (total <= 0) return null;
  return Math.round((savedTokens / total) * 100);
}

function humanizeTokens(n) {
  if (!Number.isFinite(n) || n <= 0) return '0';
  if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
  if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
  return String(Math.round(n));
}

function formatHistory({ sessions, outputTokens, estSavedTokens, estSavedUsd, since }) {
  const sep = '──────────────────────────────────';
  const window = since ? ` (last ${since})` : '';
  if (sessions === 0) {
    return `\nCaveman Stats — Lifetime${window}\n${sep}\nNo sessions logged yet — run /caveman-stats inside any session to start tracking.\n${sep}\n`;
  }
  const usdLine = estSavedUsd > 0 ? `Est. saved (USD):      ~${formatUsd(estSavedUsd)}\n` : '';
  const pct = outputReductionPct(estSavedTokens, outputTokens);
  const budgetLine = pct !== null
    ? `Est. output reduction: ~${pct}% (output tokens only, est.)\n`
    : '';
  return `\nCaveman Stats — Lifetime${window}\n${sep}\n` +
    `Sessions:   ${sessions.toLocaleString()}\n${sep}\n` +
    `Output tokens:         ${outputTokens.toLocaleString()}\n` +
    `Est. tokens saved:     ${estSavedTokens.toLocaleString()}\n` +
    budgetLine + usdLine + sep + '\n';
}

// Single-line tweetable summary. Stays human-friendly when no ratio is known.
// Savings come from per-mode attribution (#601) so a mid-session mode change
// never inflates the shared number.
function formatShare({ outputTokens, turns, mode, model, attribution }) {
  if (turns === 0) {
    return '🪨 caveman armed but no turns yet — caveman.sh';
  }
  const attr = attribution || wholeSessionAttribution(mode, outputTokens);
  const { estSavedTokens, estSavedUsd } = deriveSavings({ byMode: attr.byMode, model });

  if (estSavedTokens > 0) {
    const usd = estSavedUsd > 0 ? ` (~${formatUsd(estSavedUsd)})` : '';
    return `🪨 Saved ${estSavedTokens.toLocaleString()} output tokens${usd} across ${turns} turns this session — caveman.sh`;
  }
  return `🪨 ${turns} turns, ${outputTokens.toLocaleString()} output tokens this session — caveman.sh`;
}

// Pure formatter — separated from main() so tests can pass synthetic inputs.
// `attribution` (from attributeByMode, #601) splits output tokens per mode;
// when omitted, the current mode is assumed for the whole session.
function formatStats({ outputTokens, cacheReadTokens, turns, mode, model, sessionPath, compressed, attribution }) {
  const sep = '──────────────────────────────────';
  const shortPath = sessionPath && sessionPath.length > 45
    ? '...' + sessionPath.slice(-45)
    : (sessionPath || '');

  if (turns === 0) {
    return `\nCaveman Stats\n${sep}\nNo conversation yet — stats available after first response.\n${sep}\n`;
  }

  const attr = attribution || wholeSessionAttribution(mode, outputTokens);
  const activeKeys = Object.keys(attr.byMode).filter(k => attr.byMode[k] > 0);
  // Uniform = every token ran under the CURRENT mode. Anything else — a
  // second mode, tokens under a mode the flag no longer shows, or spans we
  // could not attribute — gets the per-mode breakdown below.
  const uniform = attr.unknownTokens === 0 &&
    (activeKeys.length === 0 || (activeKeys.length === 1 && activeKeys[0] === (mode || 'none')));

  const ratio = COMPRESSION[mode] != null ? COMPRESSION[mode] : null;
  const price = priceForModel(model);

  let savings;
  let footer = '';
  if (!uniform) {
    const { estSavedTokens, estSavedUsd } = deriveSavings({ byMode: attr.byMode, model });
    const lines = [attr.basis === 'flag-mtime'
      ? 'Mode was set mid-session — only output after the change is attributed:'
      : 'Mode changed mid-session — output attributed per mode:'];
    for (const key of activeKeys) {
      const tokens = attr.byMode[key];
      const r = COMPRESSION[key];
      const label = key === 'none' ? 'caveman off' : key;
      const note = r != null
        ? `est. ${(Math.round(tokens / (1 - r)) - tokens).toLocaleString()} saved`
        : 'no benchmark estimate';
      lines.push(`  ${label}: ${tokens.toLocaleString()} tokens (${note})`);
    }
    if (attr.unknownTokens > 0) {
      lines.push(`  unattributed: ${attr.unknownTokens.toLocaleString()} tokens (mode unknown — excluded from estimate)`);
    }
    lines.push(`Est. tokens saved:     ${estSavedTokens.toLocaleString()}`);
    if (estSavedUsd > 0) lines.push(`Est. saved (USD):      ~${formatUsd(estSavedUsd)}`);
    savings = lines.join('\n');

    footer = 'Savings est. from benchmarks/ (mean per-task), applied only to spans whose mode is known.';
    if (estSavedUsd > 0) footer += ` Pricing for ${model}.`;
    if (attr.basis === 'flag-mtime') {
      footer += ' Tokens before the mode change could not be attributed and are excluded rather than guessed.';
    } else if (attr.unknownTokens > 0) {
      footer += ' Unattributed tokens are excluded rather than guessed.';
    }
    footer += ' Reduction is of output tokens only; input/cache usage is unchanged.';
  } else if (ratio !== null) {
    const estNormal = Math.round(outputTokens / (1 - ratio));
    const estSaved = estNormal - outputTokens;
    let usdLine = '';
    if (price !== null) {
      const usd = (estSaved / 1_000_000) * price;
      usdLine = `Est. saved (USD):      ~${formatUsd(usd)}\n`;
      footer = `Savings est. from benchmarks/ (mean per-task). Pricing for ${model}. Actual varies by task.`;
    } else {
      footer = 'Savings est. from benchmarks/ (mean per-task). Actual varies by task.';
    }
    // No "% of your usage/budget" line here on purpose: from output tokens
    // alone the only computable ratio is the output reduction already shown
    // on the line above, and input + cache tokens (which dominate agentic
    // sessions and count against Pro/Max limits) are untouched by caveman —
    // any session-usage % would overstate real limit relief. See
    // docs/HONEST-NUMBERS.md.
    footer += ' Reduction is of output tokens only; input/cache usage is unchanged.';
    savings = (`Est. without caveman:  ${estNormal.toLocaleString()}\n` +
              `Est. tokens saved:     ${estSaved.toLocaleString()} (~${Math.round(ratio * 100)}% of output)\n` +
              usdLine).replace(/\n$/, '');
  } else if (mode && mode !== 'off') {
    savings = `No savings estimate for '${mode}' mode — only 'full' has benchmark data.`;
  } else {
    savings = 'Caveman not active this session.';
  }

  let memoryLine = '';
  if (compressed && compressed.count > 0) {
    const tokensApprox = compressed.tokensSaved.toLocaleString();
    memoryLine = `${sep}\nMemory compressed:     ${compressed.count} file${compressed.count === 1 ? '' : 's'}, ` +
      `~${tokensApprox} tokens saved per session start (approx)\n`;
  }

  return `\nCaveman Stats\n${sep}\n` +
    (shortPath ? `Session:  ${shortPath}\n` : '') +
    `Turns:    ${turns}\n${sep}\n` +
    `Output tokens:         ${outputTokens.toLocaleString()}\n` +
    `Cache-read tokens:     ${cacheReadTokens.toLocaleString()}\n${sep}\n` +
    `${savings}\n` +
    memoryLine +
    (footer ? footer + '\n' : '');
}

function main() {
  const args = process.argv.slice(2);
  const i = args.indexOf('--session-file');
  const sessionFileArg = i !== -1 ? args[i + 1] : null;
  const share = args.includes('--share');
  const all = args.includes('--all');
  const sinceIdx = args.indexOf('--since');
  const sinceArg = sinceIdx !== -1 ? args[sinceIdx + 1] : null;

  const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
  const historyPath = path.join(claudeDir, '.caveman-history.jsonl');

  // Lifetime aggregation paths short-circuit before we need a live session.
  if (all || sinceArg) {
    const sinceMs = parseDuration(sinceArg);
    if (sinceArg && sinceMs === null) {
      process.stderr.write(`caveman-stats: --since takes Nh or Nd (e.g. 7d, 24h), got: ${sinceArg}\n`);
      process.exit(2);
    }
    const agg = aggregateHistory(historyPath, sinceMs);
    process.stdout.write(formatHistory({ ...agg, since: sinceArg || null }));
    return;
  }

  const sessionFile = sessionFileArg || findRecentSession(claudeDir);

  if (!sessionFile) {
    process.stderr.write('caveman-stats: no Claude Code session found.\n');
    process.exit(1);
  }

  const parsed = parseSession(sessionFile);
  const flagPath = path.join(claudeDir, '.caveman-active');
  const mode = readFlag(flagPath);

  // #601: attribute tokens to the mode active when each message happened,
  // via the transition log the hooks maintain (fallbacks documented on
  // attributeByMode). Never credit the whole session to the current flag.
  let flagMtimeMs = null;
  try { flagMtimeMs = fs.statSync(flagPath).mtimeMs; } catch (e) {}
  const modeLog = readModeLog(path.join(claudeDir, MODE_LOG_BASENAME));
  const attribution = attributeByMode({
    messages: parsed.messages,
    modeLog,
    mode,
    flagMtimeMs,
    outputTokens: parsed.outputTokens,
  });

  // Append a snapshot of this session's totals to the lifetime log. Multiple
  // /caveman-stats calls in one session emit multiple lines for the same
  // session_id; aggregateHistory keeps only the latest per session_id.
  if (parsed.turns > 0) {
    const { estSavedTokens, estSavedUsd } = deriveSavings({ byMode: attribution.byMode, model: parsed.model });
    const sessionId = path.basename(sessionFile, '.jsonl');
    appendFlag(historyPath, JSON.stringify({
      ts: Date.now(),
      session_id: sessionId,
      mode: mode || null,
      model: parsed.model || null,
      output_tokens: parsed.outputTokens,
      est_saved_tokens: estSavedTokens,
      est_saved_usd: estSavedUsd,
    }));

    // Statusline suffix: tiny pre-rendered string the shell statusline can
    // cat without parsing JSONL. Updated on every /caveman-stats run.
    // Routed through safeWriteFlag — the suffix path is predictable and
    // user-owned, same symlink-clobber surface as the .caveman-active flag.
    const agg = aggregateHistory(historyPath, null);
    const suffix = agg.estSavedTokens > 0 ? `⛏  ${humanizeTokens(agg.estSavedTokens)}` : '';
    safeWriteFlag(path.join(claudeDir, '.caveman-statusline-suffix'), suffix);
  }

  if (share) {
    process.stdout.write(formatShare({ ...parsed, mode, attribution }) + '\n');
  } else {
    const scanDirs = [claudeDir, process.cwd()].filter((d, i, a) => a.indexOf(d) === i);
    const compressed = summarizeCompressed(findCompressedPairs(scanDirs));
    process.stdout.write(formatStats({ ...parsed, mode, sessionPath: sessionFile, compressed, attribution }));
  }
}

if (require.main === module) main();

module.exports = {
  formatStats, formatShare, formatHistory, aggregateHistory, parseDuration, deriveSavings,
  parseSession, priceForModel, formatUsd, COMPRESSION, MODEL_OUTPUT_PRICE_PER_M,
  findCompressedPairs, summarizeCompressed, humanizeTokens, outputReductionPct,
  readModeLog, attributeByMode,
};


"""
Unit tests for XlsxConverterWithOCR.

For each XLSX test file: convert with a mock OCR service then compare the
full output string against the expected snapshot.

OCR block format used by the converter:
    *[Image OCR]
    MOCK_OCR_TEXT_12345
    [End OCR]*

Images are grouped at the end of each sheet under:
    ### Images in this sheet:
"""

import sys
from pathlib import Path
from typing import Any

import pytest

sys.path.insert(0, str(Path(__file__).parent.parent / "src"))

from markitdown_ocr._ocr_service import OCRResult  # noqa: E402
from markitdown_ocr._xlsx_converter_with_ocr import (  # noqa: E402
    XlsxConverterWithOCR,
)
from markitdown import StreamInfo  # noqa: E402

TEST_DATA_DIR = Path(__file__).parent / "ocr_test_data"

_MOCK_TEXT = "MOCK_OCR_TEXT_12345"
_OCR_BLOCK = f"*[Image OCR]\n{_MOCK_TEXT}\n[End OCR]*"
_IMG_SECTION = "### Images in this sheet:"

class MockOCRService:
    def extract_text(
        self,  # noqa: ANN101
        image_stream: Any,
        **kwargs: Any,
    ) -> OCRResult:
        return OCRResult(text=_MOCK_TEXT, backend_used="mock")

@pytest.fixture(scope="module")
def svc() -> MockOCRService:
    return MockOCRService()

def _convert(filename: str, ocr_service: MockOCRService) -> str:
    path = TEST_DATA_DIR / filename
    if not path.exists():
        pytest.skip(f"Test file not found: {path}")
    converter = XlsxConverterWithOCR()
    with open(path, "rb") as f:
        return converter.convert(
            f, StreamInfo(extension=".xlsx"), ocr_service=ocr_service
        ).text_content

# ---------------------------------------------------------------------------
# xlsx_image_start.xlsx
# ---------------------------------------------------------------------------

def test_xlsx_image_start(svc: MockOCRService) -> None:
    expected = (
        "## Sales Q1\n\n"
        "| Product | Sales |\n"
        "| --- | --- |\n"
        "| Widget A | 100 |\n"
        "| Widget B | 150 |\n\n"
        "### Images in this sheet:\n\n"
        "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n"
        "## Forecast Q2\n\n"
        "| Projected Sales | Unnamed: 1 |\n"
        "| --- | --- |\n"
        "| Widget A | 120 |\n"
        "| Widget B | 180 |\n\n"
        "### Images in this sheet:\n\n"
        "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*"
    )
    assert _convert("xlsx_image_start.xlsx", svc) == expected

# ---------------------------------------------------------------------------
# xlsx_image_middle.xlsx
# ---------------------------------------------------------------------------

def test_xlsx_image_middle(svc: MockOCRService) -> None:
    expected = (
        "## Revenue\n\n"
        "| Q1 Report | Unnamed: 1 |\n"
        "| --- | --- |\n"
        "| NaN | NaN |\n"
        "| Revenue | $50,000 |\n"
        "| NaN | NaN |\n"
        "| NaN | NaN |\n"
        "| NaN | NaN |\n"
        "| NaN | NaN |\n"
        "| Profit Margin | 40% |\n\n"
        "### Images in this sheet:\n\n"
        "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n"
        "## Expenses\n\n"
        "| Expense Breakdown | Unnamed: 1 |\n"
        "| --- | --- |\n"
        "| NaN | NaN |\n"
        "| Expenses | $30,000 |\n"
        "| NaN | NaN |\n"
        "| NaN | NaN |\n"
        "| NaN | NaN |\n"
        "| NaN | NaN |\n"
        "| Savings | $5,000 |\n\n"
        "### Images in this sheet:\n\n"
        "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*"
    )
    assert _convert("xlsx_image_middle.xlsx", svc) == expected

# ---------------------------------------------------------------------------
# xlsx_image_end.xlsx
# ---------------------------------------------------------------------------

def test_xlsx_image_end(svc: MockOCRService) -> None:
    expected = (
        "## Sheet\n\n"
        "| Financial Summary | Unnamed: 1 |\n"
        "| --- | --- |\n"
        "| Total Revenue | $500,000 |\n"
        "| Total Expenses | $300,000 |\n"
        "| Net Profit | $200,000 |\n"
        "| NaN | NaN |\n"
        "| NaN | NaN |\n"
        "| NaN | NaN |\n"
        "| NaN | NaN |\n"
        "| NaN | NaN |\n"
        "| Signature: | NaN |\n\n"
        "### Images in this sheet:\n\n"
        "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n"
        "## Budget\n\n"
        "| Budget Allocation | Unnamed: 1 |\n"
        "| --- | --- |\n"
        "| Marketing | $100,000 |\n"
        "| R&D | $150,000 |\n"
        "| Operations | $50,000 |\n"
        "| NaN | NaN |\n"
        "| NaN | NaN |\n"
        "| NaN | NaN |\n"
        "| NaN | NaN |\n"
        "| NaN | NaN |\n"
        "| Approved: | NaN |\n\n"
        "### Images in this sheet:\n\n"
        "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*"
    )
    assert _convert("xlsx_image_end.xlsx", svc) == expected

# ---------------------------------------------------------------------------
# xlsx_multiple_images.xlsx
# ---------------------------------------------------------------------------

def test_xlsx_multiple_images(svc: MockOCRService) -> None:
    expected = (
        "## Overview\n\n"
        "| Dashboard |\n"
        "| --- |\n"
        "| Status: Active |\n"
        "| NaN |\n"
        "| NaN |\n"
        "| NaN |\n"
        "| NaN |\n"
        "| Performance Summary |\n\n"
        "### Images in this sheet:\n\n"
        "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n"
        "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n"
        "## Details\n\n"
        "| Detailed Metrics |\n"
        "| --- |\n"
        "| System Health |\n\n"
        "### Images in this sheet:\n\n"
        "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n"
        "## Summary\n\n"
        "| Quarter Summary |\n"
        "| --- |\n"
        "| Overall Performance |\n\n"
        "### Images in this sheet:\n\n"
        "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*"
    )
    assert _convert("xlsx_multiple_images.xlsx", svc) == expected

# ---------------------------------------------------------------------------
# xlsx_complex_layout.xlsx
# ---------------------------------------------------------------------------

def test_xlsx_complex_layout(svc: MockOCRService) -> None:
    expected = (
        "## Complex Report\n\n"
        "| Annual Report 2024 | Unnamed: 1 |\n"
        "| --- | --- |\n"
        "| NaN | NaN |\n"
        "| Month | Sales |\n"
        "| Jan | 1000 |\n"
        "| Feb | 1200 |\n"
        "| NaN | NaN |\n"
        "| Total | 2200 |\n\n"
        "### Images in this sheet:\n\n"
        "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n"
        "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n"
        "## Customers\n\n"
        "| Customer Metrics | Unnamed: 1 |\n"
        "| --- | --- |\n"
        "| NaN | NaN |\n"
        "| New Customers | 250 |\n"
        "| Retention Rate | 92% |\n\n"
        "### Images in this sheet:\n\n"
        "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n"
        "## Regions\n\n"
        "| Regional Breakdown | Unnamed: 1 |\n"
        "| --- | --- |\n"
        "| NaN | NaN |\n"
        "| Region | Revenue |\n"
        "| North | $800K |\n"
        "| South | $600K |\n\n"
        "### Images in this sheet:\n\n"
        "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*"
    )
    assert _convert("xlsx_complex_layout.xlsx", svc) == expected

# ---------------------------------------------------------------------------
# No OCR service — no OCR tags emitted
# ---------------------------------------------------------------------------

def test_xlsx_no_ocr_service_no_tags() -> None:
    path = TEST_DATA_DIR / "xlsx_image_middle.xlsx"
    if not path.exists():
        pytest.skip(f"Test file not found: {path}")
    converter = XlsxConverterWithOCR()
    with open(path, "rb") as f:
        md = converter.convert(f, StreamInfo(extension=".xlsx")).text_content
    assert "*[Image OCR]" not in md
    assert "[End OCR]*" not in md


# Implementation Tasks

## 1. Restructure OpenSpec README.md
- [x] 1.1 Front-load the three-stage workflow as primary content
- [x] 1.2 Restructure with hierarchy: Core Workflow → Quick Start → Commands → Details → Edge Cases
- [x] 1.3 Reduce total length by 50% (target: ~285 lines from current ~575)
- [x] 1.4 Add "Before Any Task" context-gathering checklist
- [x] 1.5 Add "Before Creating Specs" rule to check existing specs first

## 2. Add Decision Clarity
- [x] 2.1 Create clear decision trees for "Create Proposal?" scenarios
- [x] 2.2 Remove ambiguous conditions that confuse agents
- [x] 2.3 Add concrete examples for each decision branch
- [x] 2.4 Simplify bug vs feature determination logic
- [x] 2.5 Add explicit Stage 2 implementation steps (read → implement → mark complete)

## 3. Update CLI Documentation
- [x] 3.1 Document `openspec list` and `openspec list --specs` commands
- [x] 3.2 Document `openspec show` with all flags and interactive mode
- [x] 3.3 Document `openspec diff [change]` for viewing spec differences
- [x] 3.4 Document `openspec archive` with --skip-specs option
- [x] 3.5 Document `openspec validate` with --strict and batch modes
- [x] 3.6 Document `openspec init` and `openspec update` commands
- [x] 3.7 Remove all deprecated noun-first command references
- [x] 3.8 Add concrete usage examples for each command variation
- [x] 3.9 Document all flags: --json, --type, --no-interactive, etc.
- [x] 3.10 Document debugging commands: `show --json --deltas-only`

## 4. Add Spec File Documentation
- [x] 4.1 Add complete spec file structure example with ADDED/MODIFIED sections
- [x] 4.2 Document scenario formatting requirements (#### Scenario: headers)
- [x] 4.3 Explain delta file location (changes/{name}/specs/ directory)
- [x] 4.4 Show how deltas are automatically extracted
- [x] 4.5 Include warning about most common error (scenario formatting)

## 5. Add Troubleshooting Section
- [x] 5.1 Document common errors and their solutions
- [x] 5.2 Add delta detection debugging steps
- [x] 5.3 Include validation best practices (--strict flag)
- [x] 5.4 Show how to use JSON output for debugging
- [x] 5.5 Add examples of silent parsing failures

## 6. Add Agent-Specific Sections
- [x] 6.1 Add implementation workflow (read docs → implement tasks → mark complete)
- [x] 6.2 Add spec discovery workflow (check existing before creating)
- [x] 6.3 Create tool selection matrix (Grep vs Glob vs Read)
- [x] 6.4 Add error recovery patterns section
- [x] 6.5 Add context management guide
- [x] 6.6 Add verification workflows section
- [x] 6.7 Add best practices section (concise, specific, simple)

## 7. Update CLAUDE.md Template
- [x] 7.1 Update `src/core/templates/claude-template.ts` with streamlined content
- [x] 7.2 Include three-stage workflow prominently
- [x] 7.3 Add comprehensive CLI quick reference (list, show, diff, archive, etc.)
- [x] 7.4 Add "Before Any Task" checklist
- [x] 7.5 Add "Before Creating Specs" rule
- [x] 7.6 Keep complexity management principles
- [x] 7.7 Add critical scenario formatting note (#### Scenario: headers)
- [x] 7.8 Include debugging command reference

## 8. Testing and Validation
- [x] 8.1 Test all documented CLI commands for accuracy
- [x] 8.2 Run `openspec init` to verify CLAUDE.md generation
- [x] 8.3 Validate instruction clarity with example scenarios
- [x] 8.4 Ensure no critical information was lost in streamlining
- [x] 8.5 Verify decision trees eliminate ambiguity
- [x] 8.6 Test scenario formatting examples work correctly
- [x] 8.7 Verify troubleshooting steps resolve common errors


import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
import { USER_SNAPSHOT_JS } from '../xiaohongshu/user.js';
import { extractXhsUserNotes, normalizeXhsUserId } from '../xiaohongshu/user-helpers.js';

const WEB_HOST = 'www.rednote.com';

function parseLimit(raw) {
    const parsed = Number(raw ?? 15);
    if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
        throw new ArgumentError(`--limit must be a positive integer, got ${JSON.stringify(raw)}`);
    }
    if (parsed < 1) {
        throw new ArgumentError(`--limit must be a positive integer, got ${parsed}`);
    }
    return parsed;
}

export const command = cli({
    site: 'rednote',
    name: 'user',
    access: 'read',
    description: 'Get public notes from a rednote user profile',
    domain: WEB_HOST,
    strategy: Strategy.COOKIE,
    browser: true,
    navigateBefore: false,
    args: [
        { name: 'id', type: 'str', required: true, positional: true, help: 'User id or profile URL' },
        { name: 'limit', type: 'int', default: 15, help: 'Number of notes to return' },
    ],
    columns: ['id', 'title', 'type', 'likes', 'url'],
    func: async (page, kwargs) => {
        const userId = normalizeXhsUserId(String(kwargs.id));
        const limit = parseLimit(kwargs.limit);
        await page.goto(`https://${WEB_HOST}/user/profile/${userId}`);
        let snapshot = await page.evaluate(USER_SNAPSHOT_JS);
        let results = extractXhsUserNotes(snapshot ?? {}, userId, WEB_HOST);
        let previousCount = results.length;
        for (let i = 0; results.length < limit && i < 4; i += 1) {
            await page.autoScroll({ times: 1, delayMs: 1500 });
            await page.wait({ time: 1 });
            snapshot = await page.evaluate(USER_SNAPSHOT_JS);
            const nextResults = extractXhsUserNotes(snapshot ?? {}, userId, WEB_HOST);
            if (nextResults.length <= previousCount)
                break;
            results = nextResults;
            previousCount = nextResults.length;
        }
        if (results.length === 0) {
            throw new EmptyResultError('rednote/user', 'No public notes found for this rednote user.');
        }
        return results.slice(0, limit);
    },
});


/*
 * Copyright 2024-2024 the original author or authors.
 */

package io.modelcontextprotocol.server;

import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA;

import java.util.List;
import java.util.Map;

import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.GetPromptResult;
import io.modelcontextprotocol.spec.McpSchema.Prompt;
import io.modelcontextprotocol.spec.McpSchema.PromptMessage;
import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult;
import io.modelcontextprotocol.spec.McpSchema.Resource;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import io.modelcontextprotocol.spec.McpServerTransportProvider;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

/**
 * Test suite for the {@link McpSyncServer} that can be used with different
 * {@link McpServerTransportProvider} implementations.
 *
 * @author Christian Tzolov
 */
public abstract class AbstractMcpSyncServerTests {

	private static final String TEST_TOOL_NAME = "test-tool";

	private static final String TEST_RESOURCE_URI = "test://resource";

	private static final String TEST_PROMPT_NAME = "test-prompt";

	abstract protected McpServer.SyncSpecification<?> prepareSyncServerBuilder();

	protected void onStart() {
	}

	protected void onClose() {
	}

	@BeforeEach
	void setUp() {
		// onStart();
	}

	@AfterEach
	void tearDown() {
		onClose();
	}

	// ---------------------------------------
	// Server Lifecycle Tests
	// ---------------------------------------

	@Test
	void testConstructorWithInvalidArguments() {
		assertThatThrownBy(() -> McpServer.sync((McpServerTransportProvider) null))
			.isInstanceOf(IllegalArgumentException.class)
			.hasMessage("Transport provider must not be null");

		assertThatThrownBy(() -> prepareSyncServerBuilder().serverInfo(null))
			.isInstanceOf(IllegalArgumentException.class)
			.hasMessage("Server info must not be null");
	}

	@Test
	void testGracefulShutdown() {
		var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();

		assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException();
	}

	@Test
	void testImmediateClose() {
		var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();

		assertThatCode(mcpSyncServer::close).doesNotThrowAnyException();
	}

	@Test
	void testGetAsyncServer() {
		var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();

		assertThat(mcpSyncServer.getAsyncServer()).isNotNull();

		assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException();
	}

	// ---------------------------------------
	// Tools Tests
	// ---------------------------------------

	@Test
	void testAddToolCall() {
		var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
			.capabilities(ServerCapabilities.builder().tools(true).build())
			.build();

		Tool newTool = McpSchema.Tool.builder("new-tool", EMPTY_JSON_SCHEMA).title("New test tool").build();

		assertThatCode(() -> mcpSyncServer.addTool(McpServerFeatures.SyncToolSpecification.builder()
			.tool(newTool)
			.callHandler((exchange, request) -> CallToolResult.builder().content(List.of()).isError(false).build())
			.build())).doesNotThrowAnyException();

		assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException();
	}

	@Test
	void testAddDuplicateToolCall() {
		Tool duplicateTool = McpSchema.Tool.builder(TEST_TOOL_NAME, EMPTY_JSON_SCHEMA).title("Duplicate tool").build();

		var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
			.capabilities(ServerCapabilities.builder().tools(true).build())
			.toolCall(duplicateTool,
					(exchange, request) -> CallToolResult.builder().content(List.of()).isError(false).build())
			.build();

		assertThatCode(() -> mcpSyncServer.addTool(McpServerFeatures.SyncToolSpecification.builder()
			.tool(duplicateTool)
			.callHandler((exchange, request) -> CallToolResult.builder().content(List.of()).isError(false).build())
			.build())).doesNotThrowAnyException();

		assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException();
	}

	@Test
	void testDuplicateToolCallDuringBuilding() {
		Tool duplicateTool = McpSchema.Tool.builder("duplicate-build-toolcall", EMPTY_JSON_SCHEMA)
			.title("Duplicate toolcall during building")
			.build();

		assertThatThrownBy(() -> prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
			.capabilities(ServerCapabilities.builder().tools(true).build())
			.toolCall(duplicateTool,
					(exchange, request) -> CallToolResult.builder().content(List.of()).isError(false).build())
			.toolCall(duplicateTool,
					(exchange, request) -> CallToolResult.builder().content(List.of()).isError(false).build()) // Duplicate!
			.build()).isInstanceOf(IllegalArgumentException.class)
			.hasMessage("Tool with name 'duplicate-build-toolcall' is already registered.");
	}

	@Test
	void testDuplicateToolsInBatchListRegistration() {
		Tool duplicateTool = McpSchema.Tool.builder("batch-list-tool", EMPTY_JSON_SCHEMA)
			.title("Duplicate tool in batch list")
			.build();
		List<McpServerFeatures.SyncToolSpecification> specs = List.of(
				McpServerFeatures.SyncToolSpecification.builder()
					.tool(duplicateTool)
					.callHandler(
							(exchange, request) -> CallToolResult.builder().content(List.of()).isError(false).build())
					.build(),
				McpServerFeatures.SyncToolSpecification.builder()
					.tool(duplicateTool)
					.callHandler(
							(exchange, request) -> CallToolResult.builder().content(List.of()).isError(false).build())
					.build() // Duplicate!
		);

		assertThatThrownBy(() -> prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
			.capabilities(ServerCapabilities.builder().tools(true).build())
			.tools(specs)
			.build()).isInstanceOf(IllegalArgumentException.class)
			.hasMessage("Tool with name 'batch-list-tool' is already registered.");
	}

	@Test
	void testDuplicateToolsInBatchVarargsRegistration() {
		Tool duplicateTool = McpSchema.Tool.builder("batch-varargs-tool", EMPTY_JSON_SCHEMA)
			.title("Duplicate tool in batch varargs")
			.build();

		assertThatThrownBy(() -> prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
			.capabilities(ServerCapabilities.builder().tools(true).build())
			.tools(McpServerFeatures.SyncToolSpecification.builder()
				.tool(duplicateTool)
				.callHandler((exchange, request) -> CallToolResult.builder().content(List.of()).isError(false).build())
				.build(),
					McpServerFeatures.SyncToolSpecification.builder()
						.tool(duplicateTool)
						.callHandler((exchange,
								request) -> CallToolResult.builder().content(List.of()).isError(false).build())
						.build() // Duplicate!
			)
			.build()).isInstanceOf(IllegalArgumentException.class)
			.hasMessage("Tool with name 'batch-varargs-tool' is already registered.");
	}

	@Test
	void testRemoveTool() {
		Tool tool = McpSchema.Tool.builder(TEST_TOOL_NAME, EMPTY_JSON_SCHEMA).title("Test tool").build();

		var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
			.capabilities(ServerCapabilities.builder().tools(true).build())
			.toolCall(tool, (exchange, args) -> CallToolResult.builder().content(List.of()).isError(false).build())
			.build();

		assertThatCode(() -> mcpSyncServer.removeTool(TEST_TOOL_NAME)).doesNotThrowAnyException();

		assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException();
	}

	@Test
	void testRemoveNonexistentTool() {
		var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
			.capabilities(ServerCapabilities.builder().tools(true).build())
			.build();

		assertThatCode(() -> mcpSyncServer.removeTool("nonexistent-tool")).doesNotThrowAnyException();

		assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException();
	}

	@Test
	void testNotifyToolsListChanged() {
		var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();

		assertThatCode(mcpSyncServer::notifyToolsListChanged).doesNotThrowAnyException();

		assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException();
	}

	// ---------------------------------------
	// Resources Tests
	// ---------------------------------------

	@Test
	void testNotifyResourcesListChanged() {
		var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();

		assertThatCode(mcpSyncServer::notifyResourcesListChanged).doesNotThrowAnyException();

		assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException();
	}

	@Test
	void testNotifyResourcesUpdated() {
		var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();

		assertThatCode(() -> mcpSyncServer
			.notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification(TEST_RESOURCE_URI)))
			.doesNotThrowAnyException();

		assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException();
	}

	@Test
	void testAddResource() {
		var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
			.capabilities(ServerCapabilities.builder().resources(true, false).build())
			.build();

		Resource resource = Resource.builder(TEST_RESOURCE_URI, "Test Resource")
			.title("Test Resource")
			.mimeType("text/plain")
			.description("Test resource description")
			.build();
		McpServerFeatures.SyncResourceSpecification specification = new McpServerFeatures.SyncResourceSpecification(
				resource, (exchange, req) -> ReadResourceResult.builder(List.of()).build());

		assertThatCode(() -> mcpSyncServer.addResource(specification)).doesNotThrowAnyException();

		assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException();
	}

	@Test
	void testAddResourceWithNullSpecification() {
		var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
			.capabilities(ServerCapabilities.builder().resources(true, false).build())
			.build();

		assertThatThrownBy(() -> mcpSyncServer.addResource((McpServerFeatures.SyncResourceSpecification) null))
			.isInstanceOf(IllegalArgumentException.class)
			.hasMessage("Resource must not be null");

		assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException();
	}

	@Test
	void testAddResourceWithoutCapability() {
		var serverWithoutResources = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();

		Resource resource = Resource.builder(TEST_RESOURCE_URI, "Test Resource")
			.title("Test Resource")
			.mimeType("text/plain")
			.description("Test resource description")
			.build();
		McpServerFeatures.SyncResourceSpecification specification = new McpServerFeatures.SyncResourceSpecification(
				resource, (exchange, req) -> ReadResourceResult.builder(List.of()).build());

		assertThatThrownBy(() -> serverWithoutResources.addResource(specification))
			.isInstanceOf(IllegalStateException.class)
			.hasMessageContaining("Server must be configured with resource capabilities");
	}

	@Test
	void testRemoveResourceWithoutCapability() {
		var serverWithoutResources = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();

		assertThatThrownBy(() -> serverWithoutResources.removeResource(TEST_RESOURCE_URI))
			.isInstanceOf(IllegalStateException.class)
			.hasMessageContaining("Server must be configured with resource capabilities");
	}

	@Test
	void testListResources() {
		var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
			.capabilities(ServerCapabilities.builder().resources(true, false).build())
			.build();

		Resource resource = Resource.builder(TEST_RESOURCE_URI, "Test Resource")
			.title("Test Resource")
			.mimeType("text/plain")
			.description("Test resource description")
			.build();
		McpServerFeatures.SyncResourceSpecification specification = new McpServerFeatures.SyncResourceSpecification(
				resource, (exchange, req) -> ReadResourceResult.builder(List.of()).build());

		mcpSyncServer.addResource(specification);
		List<McpSchema.Resource> resources = mcpSyncServer.listResources();

		assertThat(resources).hasSize(1);
		assertThat(resources.get(0).uri()).isEqualTo(TEST_RESOURCE_URI);

		assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException();
	}

	@Test
	void testRemoveResource() {
		var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
			.capabilities(ServerCapabilities.builder().resources(true, false).build())
			.build();

		Resource resource = Resource.builder(TEST_RESOURCE_URI, "Test Resource")
			.title("Test Resource")
			.mimeType("text/plain")
			.description("Test resource description")
			.build();
		McpServerFeatures.SyncResourceSpecification specification = new McpServerFeatures.SyncResourceSpecification(
				resource, (exchange, req) -> ReadResourceResult.builder(List.of()).build());

		mcpSyncServer.addResource(specification);
		assertThatCode(() -> mcpSyncServer.removeResource(TEST_RESOURCE_URI)).doesNotThrowAnyException();

		assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException();
	}

	@Test
	void testRemoveNonexistentResource() {
		var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
			.capabilities(ServerCapabilities.builder().resources(true, false).build())
			.build();

		// Removing a non-existent resource should complete successfully (no error)
		// as per the new implementation that just logs a warning
		assertThatCode(() -> mcpSyncServer.removeResource("nonexistent://resource")).doesNotThrowAnyException();

		assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException();
	}

	// ---------------------------------------
	// Resource Template Tests
	// ---------------------------------------

	@Test
	void testAddResourceTemplate() {
		var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
			.capabilities(ServerCapabilities.builder().resources(true, false).build())
			.build();

		McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate
			.builder("test://template/{id}", "test-template")
			.description("Test resource template")
			.mimeType("text/plain")
			.build();

		McpServerFeatures.SyncResourceTemplateSpecification specification = new McpServerFeatures.SyncResourceTemplateSpecification(
				template, (exchange, req) -> ReadResourceResult.builder(List.of()).build());

		assertThatCode(() -> mcpSyncServer.addResourceTemplate(specification)).doesNotThrowAnyException();

		assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException();
	}

	@Test
	void testAddResourceTemplateWithoutCapability() {
		// Create a server without resource capabilities
		var serverWithoutResources = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();

		McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate
			.builder("test://template/{id}", "test-template")
			.description("Test resource template")
			.mimeType("text/plain")
			.build();

		McpServerFeatures.SyncResourceTemplateSpecification specification = new McpServerFeatures.SyncResourceTemplateSpecification(
				template, (exchange, req) -> ReadResourceResult.builder(List.of()).build());

		assertThatThrownBy(() -> serverWithoutResources.addResourceTemplate(specification))
			.isInstanceOf(IllegalStateException.class)
			.hasMessageContaining("Server must be configured with resource capabilities");
	}

	@Test
	void testRemoveResourceTemplate() {
		McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate
			.builder("test://template/{id}", "test-template")
			.description("Test resource template")
			.mimeType("text/plain")
			.build();

		McpServerFeatures.SyncResourceTemplateSpecification specification = new McpServerFeatures.SyncResourceTemplateSpecification(
				template, (exchange, req) -> ReadResourceResult.builder(List.of()).build());

		var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
			.capabilities(ServerCapabilities.builder().resources(true, false).build())
			.resourceTemplates(specification)
			.build();

		assertThatCode(() -> mcpSyncServer.removeResourceTemplate("test://template/{id}")).doesNotThrowAnyException();

		assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException();
	}

	@Test
	void testRemoveResourceTemplateWithoutCapability() {
		// Create a server without resource capabilities
		var serverWithoutResources = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();

		assertThatThrownBy(() -> serverWithoutResources.removeResourceTemplate("test://template/{id}"))
			.isInstanceOf(IllegalStateException.class)
			.hasMessageContaining("Server must be configured with resource capabilities");
	}

	@Test
	void testRemoveNonexistentResourceTemplate() {
		var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
			.capabilities(ServerCapabilities.builder().resources(true, false).build())
			.build();

		assertThatCode(() -> mcpSyncServer.removeResourceTemplate("nonexistent://template/{id}"))
			.doesNotThrowAnyException();

		assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException();
	}

	@Test
	void testListResourceTemplates() {
		McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate
			.builder("test://template/{id}", "test-template")
			.description("Test resource template")
			.mimeType("text/plain")
			.build();

		McpServerFeatures.SyncResourceTemplateSpecification specification = new McpServerFeatures.SyncResourceTemplateSpecification(
				template, (exchange, req) -> ReadResourceResult.builder(List.of()).build());

		var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
			.capabilities(ServerCapabilities.builder().resources(true, false).build())
			.resourceTemplates(specification)
			.build();

		List<McpSchema.ResourceTemplate> templates = mcpSyncServer.listResourceTemplates();

		assertThat(templates).isNotNull();

		assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException();
	}

	// ---------------------------------------
	// Prompts Tests
	// ---------------------------------------

	@Test
	void testNotifyPromptsListChanged() {
		var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();

		assertThatCode(mcpSyncServer::notifyPromptsListChanged).doesNotThrowAnyException();

		assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException();
	}

	@Test
	void testAddPromptWithNullSpecification() {
		var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
			.capabilities(ServerCapabilities.builder().prompts(false).build())
			.build();

		assertThatThrownBy(() -> mcpSyncServer.addPrompt((McpServerFeatures.SyncPromptSpecification) null))
			.isInstanceOf(IllegalArgumentException.class)
			.hasMessage("Prompt specification must not be null");
	}

	@Test
	void testAddPromptWithoutCapability() {
		var serverWithoutPrompts = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();

		Prompt prompt = Prompt.builder(TEST_PROMPT_NAME)
			.title("Test Prompt")
			.description("Test Prompt")
			.arguments(List.of())
			.build();
		McpServerFeatures.SyncPromptSpecification specification = new McpServerFeatures.SyncPromptSpecification(prompt,
				(exchange,
						req) -> GetPromptResult
							.builder(
									List.of(PromptMessage
										.builder(McpSchema.Role.ASSISTANT,
												McpSchema.TextContent.builder("Test content").build())
										.build()))
							.description("Test prompt description")
							.build());

		assertThatThrownBy(() -> serverWithoutPrompts.addPrompt(specification))
			.isInstanceOf(IllegalStateException.class)
			.hasMessage("Server must be configured with prompt capabilities");
	}

	@Test
	void testRemovePromptWithoutCapability() {
		var serverWithoutPrompts = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();

		assertThatThrownBy(() -> serverWithoutPrompts.removePrompt(TEST_PROMPT_NAME))
			.isInstanceOf(IllegalStateException.class)
			.hasMessage("Server must be configured with prompt capabilities");
	}

	@Test
	void testRemovePrompt() {
		Prompt prompt = Prompt.builder(TEST_PROMPT_NAME)
			.title("Test Prompt")
			.description("Test Prompt")
			.arguments(List.of())
			.build();
		McpServerFeatures.SyncPromptSpecification specification = new McpServerFeatures.SyncPromptSpecification(prompt,
				(exchange,
						req) -> GetPromptResult
							.builder(
									List.of(PromptMessage
										.builder(McpSchema.Role.ASSISTANT,
												McpSchema.TextContent.builder("Test content").build())
										.build()))
							.description("Test prompt description")
							.build());

		var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
			.capabilities(ServerCapabilities.builder().prompts(true).build())
			.prompts(specification)
			.build();

		assertThatCode(() -> mcpSyncServer.removePrompt(TEST_PROMPT_NAME)).doesNotThrowAnyException();

		assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException();
	}

	@Test
	void testRemoveNonexistentPrompt() {
		var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
			.capabilities(ServerCapabilities.builder().prompts(true).build())
			.build();

		assertThatCode(() -> mcpSyncServer.removePrompt("nonexistent://template/{id}")).doesNotThrowAnyException();

		assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException();
	}

	// ---------------------------------------
	// Roots Tests
	// ---------------------------------------

	@Test
	void testRootsChangeHandlers() {
		// Test with single consumer
		var rootsReceived = new McpSchema.Root[1];
		var consumerCalled = new boolean[1];

		var singleConsumerServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
			.rootsChangeHandlers(List.of((exchange, roots) -> {
				consumerCalled[0] = true;
				if (!roots.isEmpty()) {
					rootsReceived[0] = roots.get(0);
				}
			}))
			.build();
		assertThat(singleConsumerServer).isNotNull();
		assertThatCode(singleConsumerServer::closeGracefully).doesNotThrowAnyException();
		onClose();

		// Test with multiple consumers
		var consumer1Called = new boolean[1];
		var consumer2Called = new boolean[1];
		var rootsContent = new List[1];

		var multipleConsumersServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
			.rootsChangeHandlers(List.of((exchange, roots) -> {
				consumer1Called[0] = true;
				rootsContent[0] = roots;
			}, (exchange, roots) -> consumer2Called[0] = true))
			.build();

		assertThat(multipleConsumersServer).isNotNull();
		assertThatCode(multipleConsumersServer::closeGracefully).doesNotThrowAnyException();
		onClose();

		// Test error handling
		var errorHandlingServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
			.rootsChangeHandlers(List.of((exchange, roots) -> {
				throw new RuntimeException("Test error");
			}))
			.build();

		assertThat(errorHandlingServer).isNotNull();
		assertThatCode(errorHandlingServer::closeGracefully).doesNotThrowAnyException();
		onClose();

		// Test without consumers
		var noConsumersServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();

		assertThat(noConsumersServer).isNotNull();
		assertThatCode(noConsumersServer::closeGracefully).doesNotThrowAnyException();
	}

}