Explore the search functionality in this iOS app. I need to understand:

1. How SearchView.swift implements search UI
2. How ObsidianService.swift handles the search API call
3. What the Obsidian REST API search endpoint expects and returns
4. Any error handling or parsing issues

Focus on files:
- ObsidianViewer-iOS/Views/SearchView.swift
- ObsidianViewer-iOS/Services/ObsidianService.swift
- ObsidianViewer-iOS/Models/ObsidianModels.swift

The current error is: "The data couldn't be read because it isn't in the correct format" when trying to search.

The API response format from curl test shows:
```json
[
  {
    "filename": "path/to/file.md",
    "score": -659.539,
    "matches": [
      {
        "match": {
          "start": 29552,
          "end": 29558
        },
        "context": "...matched text with context..."
      }
    ]
  }
]
```

Find all issues and report back.


I'll explore the iOS app's search functionality to understand the implementation and identify the issues causing the "incorrect format" error.


Error: EISDIR: illegal operation on a directory, read


Now let me read the specific files related to search functionality:


     1→import SwiftUI
     2→
     3→struct SearchView: View {
     4→    @EnvironmentObject var obsidianService: ObsidianService
     5→    @EnvironmentObject var lmService: LMGatewayService
     6→
     7→    @State private var searchQuery = ""
     8→    @State private var searchResults: [ObsidianSearchResult] = []
     9→    @State private var isSearching = false
    10→    @State private var hasSearched = false
    11→    @State private var errorMessage: String?
    12→    @State private var recentSearches: [String] = []
    13→
    14→    var body: some View {
    15→        NavigationStack {
    16→            VStack(spacing: 0) {
    17→                // Search Bar
    18→                searchBar
    19→                    .padding()
    20→
    21→                // Results
    22→                if isSearching {
    23→                    loadingView
    24→                } else if let error = errorMessage {
    25→                    errorView(error)
    26→                } else if hasSearched && searchResults.isEmpty {
    27→                    noResultsView
    28→                } else if searchResults.isEmpty {
    29→                    promptView
    30→                } else {
    31→                    resultsList
    32→                }
    33→            }
    34→            .navigationTitle("Search")
    35→            .navigationBarTitleDisplayMode(.large)
    36→        }
    37→    }
    38→
    39→    // MARK: - Subviews
    40→
    41→    private var searchBar: some View {
    42→        HStack(spacing: 12) {
    43→            HStack(spacing: 10) {
    44→                Image(systemName: "magnifyingglass")
    45→                    .font(.body)
    46→                    .foregroundColor(.secondary)
    47→
    48→                TextField("Search your vault...", text: $searchQuery)
    49→                    .textFieldStyle(.plain)
    50→                    .submitLabel(.search)
    51→                    .onSubmit {
    52→                        performSearch()
    53→                    }
    54→
    55→                if !searchQuery.isEmpty {
    56→                    Button(action: { searchQuery = "" }) {
    57→                        Image(systemName: "xmark.circle.fill")
    58→                            .foregroundColor(.secondary)
    59→                    }
    60→                }
    61→            }
    62→            .padding(.horizontal, 12)
    63→            .padding(.vertical, 10)
    64→            .background(Color(.systemGray6))
    65→            .cornerRadius(12)
    66→
    67→            if !searchQuery.isEmpty {
    68→                Button("Search") {
    69→                    performSearch()
    70→                }
    71→                .font(.subheadline)
    72→                .fontWeight(.semibold)
    73→                .foregroundColor(.purple)
    74→            }
    75→        }
    76→    }
    77→
    78→    private var loadingView: some View {
    79→        VStack(spacing: 20) {
    80→            ProgressView()
    81→                .scaleEffect(1.5)
    82→
    83→            Text("Searching vault...")
    84→                .font(.subheadline)
    85→                .foregroundColor(.secondary)
    86→        }
    87→        .frame(maxWidth: .infinity, maxHeight: .infinity)
    88→    }
    89→
    90→    private func errorView(_ message: String) -> some View {
    91→        VStack(spacing: 20) {
    92→            ZStack {
    93→                Circle()
    94→                    .fill(Color.orange.opacity(0.15))
    95→                    .frame(width: 80, height: 80)
    96→
    97→                Image(systemName: "exclamationmark.triangle.fill")
    98→                    .font(.system(size: 36))
    99→                    .foregroundColor(.orange)
   100→            }
   101→
   102→            Text("Search Error")
   103→                .font(.headline)
   104→
   105→            Text(message)
   106→                .font(.subheadline)
   107→                .foregroundColor(.secondary)
   108→                .multilineTextAlignment(.center)
   109→                .padding(.horizontal, 32)
   110→
   111→            Button(action: performSearch) {
   112→                Label("Try Again", systemImage: "arrow.clockwise")
   113→            }
   114→            .buttonStyle(.borderedProminent)
   115→            .tint(.purple)
   116→        }
   117→        .frame(maxWidth: .infinity, maxHeight: .infinity)
   118→    }
   119→
   120→    private var noResultsView: some View {
   121→        VStack(spacing: 20) {
   122→            ZStack {
   123→                Circle()
   124→                    .fill(Color.gray.opacity(0.15))
   125→                    .frame(width: 80, height: 80)
   126→
   127→                Image(systemName: "magnifyingglass")
   128→                    .font(.system(size: 36))
   129→                    .foregroundColor(.gray)
   130→            }
   131→
   132→            Text("No Results Found")
   133→                .font(.headline)
   134→
   135→            Text("No notes matched \"\(searchQuery)\"")
   136→                .font(.subheadline)
   137→                .foregroundColor(.secondary)
   138→                .multilineTextAlignment(.center)
   139→
   140→            Text("Try different keywords or check spelling")
   141→                .font(.caption)
   142→                .foregroundColor(.secondary)
   143→        }
   144→        .frame(maxWidth: .infinity, maxHeight: .infinity)
   145→    }
   146→
   147→    private var promptView: some View {
   148→        VStack(spacing: 24) {
   149→            Spacer()
   150→
   151→            ZStack {
   152→                Circle()
   153→                    .fill(Color.purple.opacity(0.15))
   154→                    .frame(width: 100, height: 100)
   155→
   156→                Image(systemName: "text.magnifyingglass")
   157→                    .font(.system(size: 44))
   158→                    .foregroundColor(.purple)
   159→            }
   160→
   161→            VStack(spacing: 8) {
   162→                Text("Search Your Vault")
   163→                    .font(.title2)
   164→                    .fontWeight(.semibold)
   165→
   166→                Text("Find notes by content, title, or tags")
   167→                    .font(.subheadline)
   168→                    .foregroundColor(.secondary)
   169→            }
   170→
   171→            // Search tips
   172→            VStack(alignment: .leading, spacing: 12) {
   173→                SearchTip(icon: "doc.text", title: "Full text search", description: "Search across all note content")
   174→                SearchTip(icon: "tag", title: "Search tags", description: "Find notes with #hashtags")
   175→                SearchTip(icon: "link", title: "Find links", description: "Search for [[wikilinks]]")
   176→            }
   177→            .padding()
   178→            .background(Color(.systemGray6).opacity(0.5))
   179→            .cornerRadius(16)
   180→            .padding(.horizontal)
   181→
   182→            Spacer()
   183→        }
   184→    }
   185→
   186→    private var resultsList: some View {
   187→        List {
   188→            // Results header
   189→            Section {
   190→                HStack {
   191→                    Image(systemName: "doc.text.magnifyingglass")
   192→                        .foregroundColor(.purple)
   193→
   194→                    Text("\(searchResults.count) results for \"\(searchQuery)\"")
   195→                        .font(.subheadline)
   196→                        .foregroundColor(.secondary)
   197→                }
   198→            }
   199→
   200→            // Results
   201→            Section {
   202→                ForEach(searchResults) { result in
   203→                    SearchResultRow(result: result, searchQuery: searchQuery)
   204→                }
   205→            }
   206→        }
   207→        .listStyle(.insetGrouped)
   208→    }
   209→
   210→    // MARK: - Actions
   211→
   212→    private func performSearch() {
   213→        let query = searchQuery.trimmingCharacters(in: .whitespaces)
   214→        guard !query.isEmpty else { return }
   215→
   216→        isSearching = true
   217→        hasSearched = true
   218→        errorMessage = nil
   219→
   220→        // Save to recent searches
   221→        if !recentSearches.contains(query) {
   222→            recentSearches.insert(query, at: 0)
   223→            if recentSearches.count > 5 {
   224→                recentSearches.removeLast()
   225→            }
   226→        }
   227→
   228→        Task {
   229→            do {
   230→                searchResults = try await obsidianService.simpleSearch(query)
   231→            } catch {
   232→                errorMessage = error.localizedDescription
   233→            }
   234→            isSearching = false
   235→        }
   236→    }
   237→}
   238→
   239→// MARK: - Search Tip
   240→
   241→struct SearchTip: View {
   242→    let icon: String
   243→    let title: String
   244→    let description: String
   245→
   246→    var body: some View {
   247→        HStack(spacing: 12) {
   248→            Image(systemName: icon)
   249→                .font(.body)
   250→                .foregroundColor(.purple)
   251→                .frame(width: 24)
   252→
   253→            VStack(alignment: .leading, spacing: 2) {
   254→                Text(title)
   255→                    .font(.subheadline)
   256→                    .fontWeight(.medium)
   257→
   258→                Text(description)
   259→                    .font(.caption)
   260→                    .foregroundColor(.secondary)
   261→            }
   262→        }
   263→    }
   264→}
   265→
   266→// MARK: - Search Result Row
   267→
   268→struct SearchResultRow: View {
   269→    let result: ObsidianSearchResult
   270→    let searchQuery: String
   271→
   272→    @EnvironmentObject var obsidianService: ObsidianService
   273→    @EnvironmentObject var lmService: LMGatewayService
   274→
   275→    var body: some View {
   276→        NavigationLink(destination: FileDetailView(file: ObsidianFile(path: result.filename))) {
   277→            VStack(alignment: .leading, spacing: 10) {
   278→                // File header
   279→                HStack(spacing: 10) {
   280→                    ZStack {
   281→                        RoundedRectangle(cornerRadius: 6)
   282→                            .fill(Color.purple.opacity(0.15))
   283→                            .frame(width: 32, height: 32)
   284→
   285→                        Image(systemName: "doc.text.fill")
   286→                            .font(.system(size: 14))
   287→                            .foregroundColor(.purple)
   288→                    }
   289→
   290→                    VStack(alignment: .leading, spacing: 2) {
   291→                        Text(displayName)
   292→                            .font(.headline)
   293→                            .lineLimit(1)
   294→
   295→                        Text(result.filename)
   296→                            .font(.caption)
   297→                            .foregroundColor(.secondary)
   298→                            .lineLimit(1)
   299→                    }
   300→
   301→                    Spacer()
   302→
   303→                    // Match count badge
   304→                    Text("\(result.matches.count)")
   305→                        .font(.caption)
   306→                        .fontWeight(.semibold)
   307→                        .foregroundColor(.white)
   308→                        .padding(.horizontal, 8)
   309→                        .padding(.vertical, 4)
   310→                        .background(Capsule().fill(Color.purple))
   311→                }
   312→
   313→                // Context preview
   314→                if let firstMatch = result.matches.first, let context = firstMatch.context {
   315→                    Text(highlightedText(context))
   316→                        .font(.subheadline)
   317→                        .foregroundColor(.secondary)
   318→                        .lineLimit(3)
   319→                        .padding(10)
   320→                        .background(Color(.systemGray6))
   321→                        .cornerRadius(8)
   322→                }
   323→
   324→                // More matches indicator
   325→                if result.matches.count > 1 {
   326→                    HStack(spacing: 4) {
   327→                        Image(systemName: "ellipsis.circle")
   328→                            .font(.caption)
   329→
   330→                        Text("+\(result.matches.count - 1) more matches")
   331→                            .font(.caption)
   332→                    }
   333→                    .foregroundColor(.purple)
   334→                }
   335→            }
   336→            .padding(.vertical, 6)
   337→        }
   338→    }
   339→
   340→    private var displayName: String {
   341→        URL(fileURLWithPath: result.filename)
   342→            .deletingPathExtension()
   343→            .lastPathComponent
   344→    }
   345→
   346→    private func highlightedText(_ text: String) -> AttributedString {
   347→        var attributed = AttributedString(text)
   348→
   349→        // Find and highlight search query
   350→        let lowercasedText = text.lowercased()
   351→        let lowercasedQuery = searchQuery.lowercased()
   352→
   353→        if let range = lowercasedText.range(of: lowercasedQuery) {
   354→            let start = text.distance(from: text.startIndex, to: range.lowerBound)
   355→            let length = searchQuery.count
   356→
   357→            if let attrRange = Range(NSRange(location: start, length: length), in: attributed) {
   358→                attributed[attrRange].foregroundColor = .purple
   359→                attributed[attrRange].font = .subheadline.bold()
   360→            }
   361→        }
   362→
   363→        return attributed
   364→    }
   365→}
   366→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

import SwiftUI

struct SearchView: View {
    @EnvironmentObject var obsidianService: ObsidianService
    @EnvironmentObject var lmService: LMGatewayService

    @State private var searchQuery = ""
    @State private var searchResults: [ObsidianSearchResult] = []
    @State private var isSearching = false
    @State private var hasSearched = false
    @State private var errorMessage: String?
    @State private var recentSearches: [String] = []

    var body: some View {
        NavigationStack {
            VStack(spacing: 0) {
                // Search Bar
                searchBar
                    .padding()

                // Results
                if isSearching {
                    loadingView
                } else if let error = errorMessage {
                    errorView(error)
                } else if hasSearched && searchResults.isEmpty {
                    noResultsView
                } else if searchResults.isEmpty {
                    promptView
                } else {
                    resultsList
                }
            }
            .navigationTitle("Search")
            .navigationBarTitleDisplayMode(.large)
        }
    }

    // MARK: - Subviews

    private var searchBar: some View {
        HStack(spacing: 12) {
            HStack(spacing: 10) {
                Image(systemName: "magnifyingglass")
                    .font(.body)
                    .foregroundColor(.secondary)

                TextField("Search your vault...", text: $searchQuery)
                    .textFieldStyle(.plain)
                    .submitLabel(.search)
                    .onSubmit {
                        performSearch()
                    }

                if !searchQuery.isEmpty {
                    Button(action: { searchQuery = "" }) {
                        Image(systemName: "xmark.circle.fill")
                            .foregroundColor(.secondary)
                    }
                }
            }
            .padding(.horizontal, 12)
            .padding(.vertical, 10)
            .background(Color(.systemGray6))
            .cornerRadius(12)

            if !searchQuery.isEmpty {
                Button("Search") {
                    performSearch()
                }
                .font(.subheadline)
                .fontWeight(.semibold)
                .foregroundColor(.purple)
            }
        }
    }

    private var loadingView: some View {
        VStack(spacing: 20) {
            ProgressView()
                .scaleEffect(1.5)

            Text("Searching vault...")
                .font(.subheadline)
                .foregroundColor(.secondary)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }

    private func errorView(_ message: String) -> some View {
        VStack(spacing: 20) {
            ZStack {
                Circle()
                    .fill(Color.orange.opacity(0.15))
                    .frame(width: 80, height: 80)

                Image(systemName: "exclamationmark.triangle.fill")
                    .font(.system(size: 36))
                    .foregroundColor(.orange)
            }

            Text("Search Error")
                .font(.headline)

            Text(message)
                .font(.subheadline)
                .foregroundColor(.secondary)
                .multilineTextAlignment(.center)
                .padding(.horizontal, 32)

            Button(action: performSearch) {
                Label("Try Again", systemImage: "arrow.clockwise")
            }
            .buttonStyle(.borderedProminent)
            .tint(.purple)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }

    private var noResultsView: some View {
        VStack(spacing: 20) {
            ZStack {
                Circle()
                    .fill(Color.gray.opacity(0.15))
                    .frame(width: 80, height: 80)

                Image(systemName: "magnifyingglass")
                    .font(.system(size: 36))
                    .foregroundColor(.gray)
            }

            Text("No Results Found")
                .font(.headline)

            Text("No notes matched \"\(searchQuery)\"")
                .font(.subheadline)
                .foregroundColor(.secondary)
                .multilineTextAlignment(.center)

            Text("Try different keywords or check spelling")
                .font(.caption)
                .foregroundColor(.secondary)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }

    private var promptView: some View {
        VStack(spacing: 24) {
            Spacer()

            ZStack {
                Circle()
                    .fill(Color.purple.opacity(0.15))
                    .frame(width: 100, height: 100)

                Image(systemName: "text.magnifyingglass")
                    .font(.system(size: 44))
                    .foregroundColor(.purple)
            }

            VStack(spacing: 8) {
                Text("Search Your Vault")
                    .font(.title2)
                    .fontWeight(.semibold)

                Text("Find notes by content, title, or tags")
                    .font(.subheadline)
                    .foregroundColor(.secondary)
            }

            // Search tips
            VStack(alignment: .leading, spacing: 12) {
                SearchTip(icon: "doc.text", title: "Full text search", description: "Search across all note content")
                SearchTip(icon: "tag", title: "Search tags", description: "Find notes with #hashtags")
                SearchTip(icon: "link", title: "Find links", description: "Search for [[wikilinks]]")
            }
            .padding()
            .background(Color(.systemGray6).opacity(0.5))
            .cornerRadius(16)
            .padding(.horizontal)

            Spacer()
        }
    }

    private var resultsList: some View {
        List {
            // Results header
            Section {
                HStack {
                    Image(systemName: "doc.text.magnifyingglass")
                        .foregroundColor(.purple)

                    Text("\(searchResults.count) results for \"\(searchQuery)\"")
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                }
            }

            // Results
            Section {
                ForEach(searchResults) { result in
                    SearchResultRow(result: result, searchQuery: searchQuery)
                }
            }
        }
        .listStyle(.insetGrouped)
    }

    // MARK: - Actions

    private func performSearch() {
        let query = searchQuery.trimmingCharacters(in: .whitespaces)
        guard !query.isEmpty else { return }

        isSearching = true
        hasSearched = true
        errorMessage = nil

        // Save to recent searches
        if !recentSearches.contains(query) {
            recentSearches.insert(query, at: 0)
            if recentSearches.count > 5 {
                recentSearches.removeLast()
            }
        }

        Task {
            do {
                searchResults = try await obsidianService.simpleSearch(query)
            } catch {
                errorMessage = error.localizedDescription
            }
            isSearching = false
        }
    }
}

// MARK: - Search Tip

struct SearchTip: View {
    let icon: String
    let title: String
    let description: String

    var body: some View {
        HStack(spacing: 12) {
            Image(systemName: icon)
                .font(.body)
                .foregroundColor(.purple)
                .frame(width: 24)

            VStack(alignment: .leading, spacing: 2) {
                Text(title)
                    .font(.subheadline)
                    .fontWeight(.medium)

                Text(description)
                    .font(.caption)
                    .foregroundColor(.secondary)
            }
        }
    }
}

// MARK: - Search Result Row

struct SearchResultRow: View {
    let result: ObsidianSearchResult
    let searchQuery: String

    @EnvironmentObject var obsidianService: ObsidianService
    @EnvironmentObject var lmService: LMGatewayService

    var body: some View {
        NavigationLink(destination: FileDetailView(file: ObsidianFile(path: result.filename))) {
            VStack(alignment: .leading, spacing: 10) {
                // File header
                HStack(spacing: 10) {
                    ZStack {
                        RoundedRectangle(cornerRadius: 6)
                            .fill(Color.purple.opacity(0.15))
                            .frame(width: 32, height: 32)

                        Image(systemName: "doc.text.fill")
                            .font(.system(size: 14))
                            .foregroundColor(.purple)
                    }

                    VStack(alignment: .leading, spacing: 2) {
                        Text(displayName)
                            .font(.headline)
                            .lineLimit(1)

                        Text(result.filename)
                            .font(.caption)
                            .foregroundColor(.secondary)
                            .lineLimit(1)
                    }

                    Spacer()

                    // Match count badge
                    Text("\(result.matches.count)")
                        .font(.caption)
                        .fontWeight(.semibold)
                        .foregroundColor(.white)
                        .padding(.horizontal, 8)
                        .padding(.vertical, 4)
                        .background(Capsule().fill(Color.purple))
                }

                // Context preview
                if let firstMatch = result.matches.first, let context = firstMatch.context {
                    Text(highlightedText(context))
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                        .lineLimit(3)
                        .padding(10)
                        .background(Color(.systemGray6))
                        .cornerRadius(8)
                }

                // More matches indicator
                if result.matches.count > 1 {
                    HStack(spacing: 4) {
                        Image(systemName: "ellipsis.circle")
                            .font(.caption)

                        Text("+\(result.matches.count - 1) more matches")
                            .font(.caption)
                    }
                    .foregroundColor(.purple)
                }
            }
            .padding(.vertical, 6)
        }
    }

    private var displayName: String {
        URL(fileURLWithPath: result.filename)
            .deletingPathExtension()
            .lastPathComponent
    }

    private func highlightedText(_ text: String) -> AttributedString {
        var attributed = AttributedString(text)

        // Find and highlight search query
        let lowercasedText = text.lowercased()
        let lowercasedQuery = searchQuery.lowercased()

        if let range = lowercasedText.range(of: lowercasedQuery) {
            let start = text.distance(from: text.startIndex, to: range.lowerBound)
            let length = searchQuery.count

            if let attrRange = Range(NSRange(location: start, length: length), in: attributed) {
                attributed[attrRange].foregroundColor = .purple
                attributed[attrRange].font = .subheadline.bold()
            }
        }

        return attributed
    }
}


     1→import Foundation
     2→import Combine
     3→
     4→/// Service for interacting with Obsidian via the Local REST API plugin
     5→/// Plugin: https://github.com/coddingtonbear/obsidian-local-rest-api
     6→class ObsidianService: NSObject, ObservableObject, URLSessionDelegate {
     7→    @Published var files: [ObsidianFile] = []
     8→    @Published var isConnected: Bool = false
     9→    @Published var errorMessage: String?
    10→
    11→    private var baseURL: String
    12→    private var apiKey: String
    13→    private var cancellables = Set<AnyCancellable>()
    14→
    15→    // MARK: - Timeout Configuration
    16→
    17→    /// Differentiated timeouts for different operation types
    18→    struct TimeoutConfig {
    19→        static let connection: TimeInterval = 10      // Quick connection test
    20→        static let quickOperation: TimeInterval = 30  // Single file read, list
    21→        static let longOperation: TimeInterval = 120  // Recursive listing, batch operations
    22→    }
    23→
    24→    // MARK: - Retry Configuration
    25→
    26→    /// Retry policy for transient network errors
    27→    private let retryPolicy = RetryPolicy.default
    28→
    29→    // MARK: - URL Sessions (differentiated by timeout)
    30→
    31→    private lazy var quickSession: URLSession = {
    32→        let config = URLSessionConfiguration.default
    33→        config.timeoutIntervalForRequest = TimeoutConfig.quickOperation
    34→        config.timeoutIntervalForResource = TimeoutConfig.quickOperation * 2
    35→        return URLSession(configuration: config, delegate: self, delegateQueue: nil)
    36→    }()
    37→
    38→    private lazy var longSession: URLSession = {
    39→        let config = URLSessionConfiguration.default
    40→        config.timeoutIntervalForRequest = TimeoutConfig.longOperation
    41→        config.timeoutIntervalForResource = TimeoutConfig.longOperation * 2
    42→        return URLSession(configuration: config, delegate: self, delegateQueue: nil)
    43→    }()
    44→
    45→    private lazy var connectionTestSession: URLSession = {
    46→        let config = URLSessionConfiguration.default
    47→        config.timeoutIntervalForRequest = TimeoutConfig.connection
    48→        config.timeoutIntervalForResource = TimeoutConfig.connection
    49→        return URLSession(configuration: config, delegate: self, delegateQueue: nil)
    50→    }()
    51→
    52→    /// Initialize with credentials from Keychain or provided values
    53→    /// Note: Credentials should be configured via Keychain, not hardcoded
    54→    init(baseURL: String? = nil, apiKey: String? = nil) {
    55→        self.baseURL = baseURL ?? Keychain.obsidianAPIUrl ?? ""
    56→        self.apiKey = apiKey ?? Keychain.obsidianAPIKey ?? ""
    57→        super.init()
    58→    }
    59→
    60→    /// Check if service is properly configured with credentials
    61→    var isConfigured: Bool {
    62→        !baseURL.isEmpty && !apiKey.isEmpty
    63→    }
    64→
    65→    func configure(baseURL: String, apiKey: String, saveToKeychain: Bool = true) {
    66→        self.baseURL = baseURL
    67→        self.apiKey = apiKey
    68→
    69→        if saveToKeychain {
    70→            Keychain.obsidianAPIUrl = baseURL
    71→            Keychain.obsidianAPIKey = apiKey
    72→        }
    73→    }
    74→
    75→    // MARK: - URLSessionDelegate (Certificate Validation)
    76→
    77→    /// Trusted hosts for self-signed certificates (local Obsidian instances)
    78→    private static let trustedHosts: Set<String> = [
    79→        "127.0.0.1",
    80→        "localhost",
    81→        "192.168.1.125"  // Local network Obsidian proxy
    82→    ]
    83→
    84→    func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
    85→        guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
    86→              let serverTrust = challenge.protectionSpace.serverTrust else {
    87→            completionHandler(.performDefaultHandling, nil)
    88→            return
    89→        }
    90→
    91→        let host = challenge.protectionSpace.host
    92→
    93→        // Only accept self-signed certificates for trusted local hosts
    94→        if Self.trustedHosts.contains(host) {
    95→            // Log certificate acceptance for audit
    96→            Log.debug("Accepting self-signed certificate for trusted host: \(host)", category: .network)
    97→
    98→            // Evaluate the server trust
    99→            var error: CFError?
   100→            let isValid = SecTrustEvaluateWithError(serverTrust, &error)
   101→
   102→            if isValid {
   103→                // Certificate chain is valid
   104→                let credential = URLCredential(trust: serverTrust)
   105→                completionHandler(.useCredential, credential)
   106→            } else {
   107→                // Self-signed or invalid chain - accept for local hosts only
   108→                Log.warning("Self-signed certificate accepted for local host: \(host)", category: .network)
   109→                let credential = URLCredential(trust: serverTrust)
   110→                completionHandler(.useCredential, credential)
   111→            }
   112→        } else {
   113→            // For non-local hosts, use standard certificate validation
   114→            Log.info("Using standard certificate validation for host: \(host)", category: .network)
   115→            completionHandler(.performDefaultHandling, nil)
   116→        }
   117→    }
   118→
   119→    // MARK: - Connection Test
   120→
   121→    func testConnection() async -> Bool {
   122→        guard isConfigured else {
   123→            Log.warning("Cannot test connection - service not configured", category: .network)
   124→            return false
   125→        }
   126→
   127→        guard let url = URL(string: "\(baseURL)/") else { return false }
   128→
   129→        var request = URLRequest(url: url)
   130→        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
   131→
   132→        Log.debug("Testing connection to: \(baseURL)", category: .network)
   133→
   134→        do {
   135→            let (_, response) = try await connectionTestSession.data(for: request)
   136→            let httpResponse = response as? HTTPURLResponse
   137→            let connected = httpResponse?.statusCode == 200
   138→
   139→            Log.info("Connection test result: \(connected ? "SUCCESS" : "FAILED") (status: \(httpResponse?.statusCode ?? -1))", category: .network)
   140→
   141→            await MainActor.run {
   142→                self.isConnected = connected
   143→            }
   144→            return connected
   145→        } catch {
   146→            Log.error("Connection test failed: \(error.localizedDescription)", category: .network)
   147→
   148→            await MainActor.run {
   149→                self.isConnected = false
   150→                self.errorMessage = error.localizedDescription
   151→            }
   152→            return false
   153→        }
   154→    }
   155→
   156→    // MARK: - List Files
   157→
   158→    func listFilesInVault() async throws -> [ObsidianFile] {
   159→        guard isConfigured else {
   160→            Log.warning("Cannot list files - service not configured", category: .network)
   161→            throw ObsidianError.notConnected
   162→        }
   163→
   164→        guard let url = URL(string: "\(baseURL)/vault/") else {
   165→            throw ObsidianError.invalidURL
   166→        }
   167→
   168→        Log.debug("Listing vault files at: \(url)", category: .network)
   169→
   170→        return try await Retry.execute(policy: retryPolicy, operation: "listFilesInVault") {
   171→            var request = URLRequest(url: url)
   172→            request.setValue("Bearer \(self.apiKey)", forHTTPHeaderField: "Authorization")
   173→            request.setValue("application/json", forHTTPHeaderField: "Accept")
   174→
   175→            let (data, response) = try await self.quickSession.data(for: request)
   176→
   177→            guard let httpResponse = response as? HTTPURLResponse else {
   178→                throw ObsidianError.invalidResponse
   179→            }
   180→
   181→            guard httpResponse.statusCode == 200 else {
   182→                Log.warning("listFilesInVault HTTP \(httpResponse.statusCode)", category: .network)
   183→                throw ObsidianError.httpError(httpResponse.statusCode)
   184→            }
   185→
   186→            // API returns {"files": [...]}
   187→            let vaultResponse = try JSONDecoder().decode(VaultFilesResponse.self, from: data)
   188→
   189→            let files = vaultResponse.files.map { path in
   190→                ObsidianFile(
   191→                    path: path,
   192→                    isDirectory: path.hasSuffix("/")
   193→                )
   194→            }
   195→
   196→            Log.info("Listed \(files.count) files in vault", category: .network)
   197→
   198→            await MainActor.run {
   199→                self.files = files
   200→            }
   201→
   202→            return files
   203→        }
   204→    }
   205→
   206→    func listFilesInDirectory(_ directory: String) async throws -> [ObsidianFile] {
   207→        // Remove trailing slash if present to avoid double slashes
   208→        var cleanPath = directory
   209→        if cleanPath.hasSuffix("/") {
   210→            cleanPath = String(cleanPath.dropLast())
   211→        }
   212→
   213→        let encodedPath = cleanPath.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? cleanPath
   214→        guard let url = URL(string: "\(baseURL)/vault/\(encodedPath)/") else {
   215→            throw ObsidianError.invalidURL
   216→        }
   217→
   218→        return try await Retry.execute(policy: retryPolicy, operation: "listFilesInDirectory") {
   219→            var request = URLRequest(url: url)
   220→            request.setValue("Bearer \(self.apiKey)", forHTTPHeaderField: "Authorization")
   221→            request.setValue("application/json", forHTTPHeaderField: "Accept")
   222→
   223→            let (data, response) = try await self.quickSession.data(for: request)
   224→
   225→            guard let httpResponse = response as? HTTPURLResponse else {
   226→                throw ObsidianError.invalidResponse
   227→            }
   228→
   229→            guard httpResponse.statusCode == 200 else {
   230→                throw ObsidianError.httpError(httpResponse.statusCode)
   231→            }
   232→
   233→            // API returns {"files": [...]}
   234→            let vaultResponse = try JSONDecoder().decode(VaultFilesResponse.self, from: data)
   235→
   236→            // Prepend the directory path to each file
   237→            return vaultResponse.files.map { fileName in
   238→                let fullPath = cleanPath.isEmpty ? fileName : "\(cleanPath)/\(fileName)"
   239→                return ObsidianFile(
   240→                    path: fullPath,
   241→                    isDirectory: fileName.hasSuffix("/")
   242→                )
   243→            }
   244→        }
   245→    }
   246→
   247→    // MARK: - Recursive File Listing
   248→
   249→    /// Lista TODOS os arquivos do vault recursivamente
   250→    func listAllFilesRecursively() async throws -> [ObsidianFile] {
   251→        var allFiles: [ObsidianFile] = []
   252→
   253→        func traverse(_ directory: String) async throws {
   254→            let files = directory.isEmpty
   255→                ? try await listFilesInVault()
   256→                : try await listFilesInDirectory(directory)
   257→
   258→            for file in files {
   259→                allFiles.append(file)
   260→                if file.isDirectory {
   261→                    let cleanPath = file.path.hasSuffix("/")
   262→                        ? String(file.path.dropLast())
   263→                        : file.path
   264→                    try await traverse(cleanPath)
   265→                }
   266→            }
   267→        }
   268→
   269→        try await traverse("")
   270→        return allFiles
   271→    }
   272→
   273→    /// Lista apenas arquivos .md recursivamente
   274→    func listAllMarkdownFiles() async throws -> [ObsidianFile] {
   275→        let allFiles = try await listAllFilesRecursively()
   276→        return allFiles.filter { $0.path.hasSuffix(".md") && !$0.isDirectory }
   277→    }
   278→
   279→    /// Estatísticas do vault
   280→    func getVaultStats() async throws -> VaultStats {
   281→        let allFiles = try await listAllFilesRecursively()
   282→        let mdFiles = allFiles.filter { $0.path.hasSuffix(".md") }
   283→        let folders = allFiles.filter { $0.isDirectory }
   284→
   285→        return VaultStats(
   286→            totalFiles: allFiles.count,
   287→            markdownFiles: mdFiles.count,
   288→            folders: folders.count,
   289→            lastUpdated: Date()
   290→        )
   291→    }
   292→
   293→    // MARK: - Get File Contents
   294→
   295→    func getFileContents(_ path: String) async throws -> String {
   296→        let encodedPath = path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? path
   297→        guard let url = URL(string: "\(baseURL)/vault/\(encodedPath)") else {
   298→            throw ObsidianError.invalidURL
   299→        }
   300→
   301→        return try await Retry.execute(policy: retryPolicy, operation: "getFileContents") {
   302→            var request = URLRequest(url: url)
   303→            request.setValue("Bearer \(self.apiKey)", forHTTPHeaderField: "Authorization")
   304→            request.setValue("text/markdown", forHTTPHeaderField: "Accept")
   305→
   306→            let (data, response) = try await self.quickSession.data(for: request)
   307→
   308→            guard let httpResponse = response as? HTTPURLResponse else {
   309→                throw ObsidianError.invalidResponse
   310→            }
   311→
   312→            guard httpResponse.statusCode == 200 else {
   313→                throw ObsidianError.httpError(httpResponse.statusCode)
   314→            }
   315→
   316→            return String(data: data, encoding: .utf8) ?? ""
   317→        }
   318→    }
   319→
   320→    func getBatchFileContents(_ paths: [String]) async throws -> [String: String] {
   321→        var results: [String: String] = [:]
   322→
   323→        Log.debug("Fetching batch contents for \(paths.count) files", category: .network)
   324→        let startTime = Date()
   325→
   326→        // Use longSession for batch operations with controlled concurrency
   327→        await withTaskGroup(of: (String, String?).self) { group in
   328→            for path in paths {
   329→                group.addTask {
   330→                    let encodedPath = path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? path
   331→                    guard let url = URL(string: "\(self.baseURL)/vault/\(encodedPath)") else {
   332→                        return (path, nil)
   333→                    }
   334→
   335→                    do {
   336→                        return try await Retry.execute(policy: .quick, operation: "getBatchFile") {
   337→                            var request = URLRequest(url: url)
   338→                            request.setValue("Bearer \(self.apiKey)", forHTTPHeaderField: "Authorization")
   339→                            request.setValue("text/markdown", forHTTPHeaderField: "Accept")
   340→
   341→                            let (data, response) = try await self.longSession.data(for: request)
   342→
   343→                            guard let httpResponse = response as? HTTPURLResponse,
   344→                                  httpResponse.statusCode == 200 else {
   345→                                return (path, nil)
   346→                            }
   347→
   348→                            return (path, String(data: data, encoding: .utf8))
   349→                        }
   350→                    } catch {
   351→                        return (path, nil)
   352→                    }
   353→                }
   354→            }
   355→
   356→            for await (path, content) in group {
   357→                if let content = content {
   358→                    results[path] = content
   359→                }
   360→            }
   361→        }
   362→
   363→        let elapsed = Date().timeIntervalSince(startTime)
   364→        Log.info("Batch fetch completed: \(results.count)/\(paths.count) files in \(String(format: "%.2f", elapsed))s", category: .network)
   365→
   366→        return results
   367→    }
   368→
   369→    // MARK: - Search
   370→
   371→    func simpleSearch(_ query: String) async throws -> [ObsidianSearchResult] {
   372→        // Build URL with query parameter
   373→        var components = URLComponents(string: "\(baseURL)/search/simple/")
   374→        components?.queryItems = [
   375→            URLQueryItem(name: "query", value: query),
   376→            URLQueryItem(name: "contextLength", value: "100")
   377→        ]
   378→
   379→        guard let url = components?.url else {
   380→            throw ObsidianError.invalidURL
   381→        }
   382→
   383→        Log.debug("Searching vault for: \(query)", category: .network)
   384→
   385→        return try await Retry.execute(policy: retryPolicy, operation: "simpleSearch") {
   386→            var request = URLRequest(url: url)
   387→            request.httpMethod = "POST"
   388→            request.setValue("Bearer \(self.apiKey)", forHTTPHeaderField: "Authorization")
   389→            request.setValue("application/json", forHTTPHeaderField: "Accept")
   390→
   391→            let (data, response) = try await self.quickSession.data(for: request)
   392→
   393→            guard let httpResponse = response as? HTTPURLResponse else {
   394→                throw ObsidianError.invalidResponse
   395→            }
   396→
   397→            guard httpResponse.statusCode == 200 else {
   398→                throw ObsidianError.httpError(httpResponse.statusCode)
   399→            }
   400→
   401→            // Try to decode the search results
   402→            do {
   403→                let results = try JSONDecoder().decode([ObsidianSearchResult].self, from: data)
   404→                Log.info("Search returned \(results.count) results for: \(query)", category: .network)
   405→                return results
   406→            } catch {
   407→                // Log the raw response for debugging
   408→                if let jsonString = String(data: data, encoding: .utf8) {
   409→                    Log.error("Search JSON decode failed. Response preview: \(String(jsonString.prefix(500)))", category: .network)
   410→                }
   411→                throw error
   412→            }
   413→        }
   414→    }
   415→
   416→    // MARK: - Recent Changes
   417→
   418→    func getRecentChanges(limit: Int = 20) async throws -> [ObsidianFile] {
   419→        guard let url = URL(string: "\(baseURL)/periodic/daily/") else {
   420→            // Fallback: return all files sorted by modification
   421→            let allFiles = try await listFilesInVault()
   422→            return Array(allFiles.prefix(limit))
   423→        }
   424→
   425→        do {
   426→            return try await Retry.execute(policy: .quick, operation: "getRecentChanges") {
   427→                var request = URLRequest(url: url)
   428→                request.setValue("Bearer \(self.apiKey)", forHTTPHeaderField: "Authorization")
   429→                request.setValue("application/json", forHTTPHeaderField: "Accept")
   430→
   431→                let (data, response) = try await self.quickSession.data(for: request)
   432→
   433→                guard let httpResponse = response as? HTTPURLResponse,
   434→                      httpResponse.statusCode == 200 else {
   435→                    throw ObsidianError.invalidResponse
   436→                }
   437→
   438→                let recentResponse = try JSONDecoder().decode(RecentChangesResponse.self, from: data)
   439→
   440→                return recentResponse.files.map { file in
   441→                    ObsidianFile(path: file.path, lastModified: file.modified)
   442→                }
   443→            }
   444→        } catch {
   445→            // Fallback to listing all files on error
   446→            Log.warning("getRecentChanges failed, using fallback: \(error.localizedDescription)", category: .network)
   447→            let allFiles = try await listFilesInVault()
   448→            return Array(allFiles.filter { $0.path.hasSuffix(".md") }.prefix(limit))
   449→        }
   450→    }
   451→
   452→    // MARK: - Write/Append Content
   453→
   454→    func appendContent(to path: String, content: String) async throws {
   455→        let encodedPath = path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? path
   456→        guard let url = URL(string: "\(baseURL)/vault/\(encodedPath)") else {
   457→            throw ObsidianError.invalidURL
   458→        }
   459→
   460→        Log.debug("Appending content to: \(path)", category: .network)
   461→
   462→        try await Retry.execute(policy: retryPolicy, operation: "appendContent") {
   463→            var request = URLRequest(url: url)
   464→            request.httpMethod = "POST"
   465→            request.setValue("Bearer \(self.apiKey)", forHTTPHeaderField: "Authorization")
   466→            request.setValue("text/markdown", forHTTPHeaderField: "Content-Type")
   467→            request.httpBody = content.data(using: .utf8)
   468→
   469→            let (_, response) = try await self.quickSession.data(for: request)
   470→
   471→            guard let httpResponse = response as? HTTPURLResponse else {
   472→                throw ObsidianError.invalidResponse
   473→            }
   474→
   475→            guard httpResponse.statusCode == 200 || httpResponse.statusCode == 204 else {
   476→                throw ObsidianError.httpError(httpResponse.statusCode)
   477→            }
   478→
   479→            Log.info("Content appended to: \(path)", category: .network)
   480→        }
   481→    }
   482→
   483→    func createFile(at path: String, content: String) async throws {
   484→        let encodedPath = path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? path
   485→        guard let url = URL(string: "\(baseURL)/vault/\(encodedPath)") else {
   486→            throw ObsidianError.invalidURL
   487→        }
   488→
   489→        Log.debug("Creating file at: \(path)", category: .network)
   490→
   491→        try await Retry.execute(policy: retryPolicy, operation: "createFile") {
   492→            var request = URLRequest(url: url)
   493→            request.httpMethod = "PUT"
   494→            request.setValue("Bearer \(self.apiKey)", forHTTPHeaderField: "Authorization")
   495→            request.setValue("text/markdown", forHTTPHeaderField: "Content-Type")
   496→            request.httpBody = content.data(using: .utf8)
   497→
   498→            let (_, response) = try await self.quickSession.data(for: request)
   499→
   500→            guard let httpResponse = response as? HTTPURLResponse else {
   501→                throw ObsidianError.invalidResponse
   502→            }
   503→
   504→            guard httpResponse.statusCode == 200 || httpResponse.statusCode == 204 else {
   505→                throw ObsidianError.httpError(httpResponse.statusCode)
   506→            }
   507→
   508→            Log.info("File created at: \(path)", category: .network)
   509→        }
   510→    }
   511→}
   512→
   513→// MARK: - Errors
   514→
   515→enum ObsidianError: LocalizedError {
   516→    case invalidURL
   517→    case invalidResponse
   518→    case httpError(Int)
   519→    case notConnected
   520→    case decodingError
   521→
   522→    var errorDescription: String? {
   523→        switch self {
   524→        case .invalidURL:
   525→            return "Invalid URL"
   526→        case .invalidResponse:
   527→            return "Invalid response from server"
   528→        case .httpError(let code):
   529→            return "HTTP error: \(code)"
   530→        case .notConnected:
   531→            return "Not connected to Obsidian"
   532→        case .decodingError:
   533→            return "Failed to decode response"
   534→        }
   535→    }
   536→}
   537→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

import Foundation
import Combine

/// Service for interacting with Obsidian via the Local REST API plugin
/// Plugin: https://github.com/coddingtonbear/obsidian-local-rest-api
class ObsidianService: NSObject, ObservableObject, URLSessionDelegate {
    @Published var files: [ObsidianFile] = []
    @Published var isConnected: Bool = false
    @Published var errorMessage: String?

    private var baseURL: String
    private var apiKey: String
    private var cancellables = Set<AnyCancellable>()

    // MARK: - Timeout Configuration

    /// Differentiated timeouts for different operation types
    struct TimeoutConfig {
        static let connection: TimeInterval = 10      // Quick connection test
        static let quickOperation: TimeInterval = 30  // Single file read, list
        static let longOperation: TimeInterval = 120  // Recursive listing, batch operations
    }

    // MARK: - Retry Configuration

    /// Retry policy for transient network errors
    private let retryPolicy = RetryPolicy.default

    // MARK: - URL Sessions (differentiated by timeout)

    private lazy var quickSession: URLSession = {
        let config = URLSessionConfiguration.default
        config.timeoutIntervalForRequest = TimeoutConfig.quickOperation
        config.timeoutIntervalForResource = TimeoutConfig.quickOperation * 2
        return URLSession(configuration: config, delegate: self, delegateQueue: nil)
    }()

    private lazy var longSession: URLSession = {
        let config = URLSessionConfiguration.default
        config.timeoutIntervalForRequest = TimeoutConfig.longOperation
        config.timeoutIntervalForResource = TimeoutConfig.longOperation * 2
        return URLSession(configuration: config, delegate: self, delegateQueue: nil)
    }()

    private lazy var connectionTestSession: URLSession = {
        let config = URLSessionConfiguration.default
        config.timeoutIntervalForRequest = TimeoutConfig.connection
        config.timeoutIntervalForResource = TimeoutConfig.connection
        return URLSession(configuration: config, delegate: self, delegateQueue: nil)
    }()

    /// Initialize with credentials from Keychain or provided values
    /// Note: Credentials should be configured via Keychain, not hardcoded
    init(baseURL: String? = nil, apiKey: String? = nil) {
        self.baseURL = baseURL ?? Keychain.obsidianAPIUrl ?? ""
        self.apiKey = apiKey ?? Keychain.obsidianAPIKey ?? ""
        super.init()
    }

    /// Check if service is properly configured with credentials
    var isConfigured: Bool {
        !baseURL.isEmpty && !apiKey.isEmpty
    }

    func configure(baseURL: String, apiKey: String, saveToKeychain: Bool = true) {
        self.baseURL = baseURL
        self.apiKey = apiKey

        if saveToKeychain {
            Keychain.obsidianAPIUrl = baseURL
            Keychain.obsidianAPIKey = apiKey
        }
    }

    // MARK: - URLSessionDelegate (Certificate Validation)

    /// Trusted hosts for self-signed certificates (local Obsidian instances)
    private static let trustedHosts: Set<String> = [
        "127.0.0.1",
        "localhost",
        "192.168.1.125"  // Local network Obsidian proxy
    ]

    func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
        guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
              let serverTrust = challenge.protectionSpace.serverTrust else {
            completionHandler(.performDefaultHandling, nil)
            return
        }

        let host = challenge.protectionSpace.host

        // Only accept self-signed certificates for trusted local hosts
        if Self.trustedHosts.contains(host) {
            // Log certificate acceptance for audit
            Log.debug("Accepting self-signed certificate for trusted host: \(host)", category: .network)

            // Evaluate the server trust
            var error: CFError?
            let isValid = SecTrustEvaluateWithError(serverTrust, &error)

            if isValid {
                // Certificate chain is valid
                let credential = URLCredential(trust: serverTrust)
                completionHandler(.useCredential, credential)
            } else {
                // Self-signed or invalid chain - accept for local hosts only
                Log.warning("Self-signed certificate accepted for local host: \(host)", category: .network)
                let credential = URLCredential(trust: serverTrust)
                completionHandler(.useCredential, credential)
            }
        } else {
            // For non-local hosts, use standard certificate validation
            Log.info("Using standard certificate validation for host: \(host)", category: .network)
            completionHandler(.performDefaultHandling, nil)
        }
    }

    // MARK: - Connection Test

    func testConnection() async -> Bool {
        guard isConfigured else {
            Log.warning("Cannot test connection - service not configured", category: .network)
            return false
        }

        guard let url = URL(string: "\(baseURL)/") else { return false }

        var request = URLRequest(url: url)
        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")

        Log.debug("Testing connection to: \(baseURL)", category: .network)

        do {
            let (_, response) = try await connectionTestSession.data(for: request)
            let httpResponse = response as? HTTPURLResponse
            let connected = httpResponse?.statusCode == 200

            Log.info("Connection test result: \(connected ? "SUCCESS" : "FAILED") (status: \(httpResponse?.statusCode ?? -1))", category: .network)

            await MainActor.run {
                self.isConnected = connected
            }
            return connected
        } catch {
            Log.error("Connection test failed: \(error.localizedDescription)", category: .network)

            await MainActor.run {
                self.isConnected = false
                self.errorMessage = error.localizedDescription
            }
            return false
        }
    }

    // MARK: - List Files

    func listFilesInVault() async throws -> [ObsidianFile] {
        guard isConfigured else {
            Log.warning("Cannot list files - service not configured", category: .network)
            throw ObsidianError.notConnected
        }

        guard let url = URL(string: "\(baseURL)/vault/") else {
            throw ObsidianError.invalidURL
        }

        Log.debug("Listing vault files at: \(url)", category: .network)

        return try await Retry.execute(policy: retryPolicy, operation: "listFilesInVault") {
            var request = URLRequest(url: url)
            request.setValue("Bearer \(self.apiKey)", forHTTPHeaderField: "Authorization")
            request.setValue("application/json", forHTTPHeaderField: "Accept")

            let (data, response) = try await self.quickSession.data(for: request)

            guard let httpResponse = response as? HTTPURLResponse else {
                throw ObsidianError.invalidResponse
            }

            guard httpResponse.statusCode == 200 else {
                Log.warning("listFilesInVault HTTP \(httpResponse.statusCode)", category: .network)
                throw ObsidianError.httpError(httpResponse.statusCode)
            }

            // API returns {"files": [...]}
            let vaultResponse = try JSONDecoder().decode(VaultFilesResponse.self, from: data)

            let files = vaultResponse.files.map { path in
                ObsidianFile(
                    path: path,
                    isDirectory: path.hasSuffix("/")
                )
            }

            Log.info("Listed \(files.count) files in vault", category: .network)

            await MainActor.run {
                self.files = files
            }

            return files
        }
    }

    func listFilesInDirectory(_ directory: String) async throws -> [ObsidianFile] {
        // Remove trailing slash if present to avoid double slashes
        var cleanPath = directory
        if cleanPath.hasSuffix("/") {
            cleanPath = String(cleanPath.dropLast())
        }

        let encodedPath = cleanPath.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? cleanPath
        guard let url = URL(string: "\(baseURL)/vault/\(encodedPath)/") else {
            throw ObsidianError.invalidURL
        }

        return try await Retry.execute(policy: retryPolicy, operation: "listFilesInDirectory") {
            var request = URLRequest(url: url)
            request.setValue("Bearer \(self.apiKey)", forHTTPHeaderField: "Authorization")
            request.setValue("application/json", forHTTPHeaderField: "Accept")

            let (data, response) = try await self.quickSession.data(for: request)

            guard let httpResponse = response as? HTTPURLResponse else {
                throw ObsidianError.invalidResponse
            }

            guard httpResponse.statusCode == 200 else {
                throw ObsidianError.httpError(httpResponse.statusCode)
            }

            // API returns {"files": [...]}
            let vaultResponse = try JSONDecoder().decode(VaultFilesResponse.self, from: data)

            // Prepend the directory path to each file
            return vaultResponse.files.map { fileName in
                let fullPath = cleanPath.isEmpty ? fileName : "\(cleanPath)/\(fileName)"
                return ObsidianFile(
                    path: fullPath,
                    isDirectory: fileName.hasSuffix("/")
                )
            }
        }
    }

    // MARK: - Recursive File Listing

    /// Lista TODOS os arquivos do vault recursivamente
    func listAllFilesRecursively() async throws -> [ObsidianFile] {
        var allFiles: [ObsidianFile] = []

        func traverse(_ directory: String) async throws {
            let files = directory.isEmpty
                ? try await listFilesInVault()
                : try await listFilesInDirectory(directory)

            for file in files {
                allFiles.append(file)
                if file.isDirectory {
                    let cleanPath = file.path.hasSuffix("/")
                        ? String(file.path.dropLast())
                        : file.path
                    try await traverse(cleanPath)
                }
            }
        }

        try await traverse("")
        return allFiles
    }

    /// Lista apenas arquivos .md recursivamente
    func listAllMarkdownFiles() async throws -> [ObsidianFile] {
        let allFiles = try await listAllFilesRecursively()
        return allFiles.filter { $0.path.hasSuffix(".md") && !$0.isDirectory }
    }

    /// Estatísticas do vault
    func getVaultStats() async throws -> VaultStats {
        let allFiles = try await listAllFilesRecursively()
        let mdFiles = allFiles.filter { $0.path.hasSuffix(".md") }
        let folders = allFiles.filter { $0.isDirectory }

        return VaultStats(
            totalFiles: allFiles.count,
            markdownFiles: mdFiles.count,
            folders: folders.count,
            lastUpdated: Date()
        )
    }

    // MARK: - Get File Contents

    func getFileContents(_ path: String) async throws -> String {
        let encodedPath = path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? path
        guard let url = URL(string: "\(baseURL)/vault/\(encodedPath)") else {
            throw ObsidianError.invalidURL
        }

        return try await Retry.execute(policy: retryPolicy, operation: "getFileContents") {
            var request = URLRequest(url: url)
            request.setValue("Bearer \(self.apiKey)", forHTTPHeaderField: "Authorization")
            request.setValue("text/markdown", forHTTPHeaderField: "Accept")

            let (data, response) = try await self.quickSession.data(for: request)

            guard let httpResponse = response as? HTTPURLResponse else {
                throw ObsidianError.invalidResponse
            }

            guard httpResponse.statusCode == 200 else {
                throw ObsidianError.httpError(httpResponse.statusCode)
            }

            return String(data: data, encoding: .utf8) ?? ""
        }
    }

    func getBatchFileContents(_ paths: [String]) async throws -> [String: String] {
        var results: [String: String] = [:]

        Log.debug("Fetching batch contents for \(paths.count) files", category: .network)
        let startTime = Date()

        // Use longSession for batch operations with controlled concurrency
        await withTaskGroup(of: (String, String?).self) { group in
            for path in paths {
                group.addTask {
                    let encodedPath = path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? path
                    guard let url = URL(string: "\(self.baseURL)/vault/\(encodedPath)") else {
                        return (path, nil)
                    }

                    do {
                        return try await Retry.execute(policy: .quick, operation: "getBatchFile") {
                            var request = URLRequest(url: url)
                            request.setValue("Bearer \(self.apiKey)", forHTTPHeaderField: "Authorization")
                            request.setValue("text/markdown", forHTTPHeaderField: "Accept")

                            let (data, response) = try await self.longSession.data(for: request)

                            guard let httpResponse = response as? HTTPURLResponse,
                                  httpResponse.statusCode == 200 else {
                                return (path, nil)
                            }

                            return (path, String(data: data, encoding: .utf8))
                        }
                    } catch {
                        return (path, nil)
                    }
                }
            }

            for await (path, content) in group {
                if let content = content {
                    results[path] = content
                }
            }
        }

        let elapsed = Date().timeIntervalSince(startTime)
        Log.info("Batch fetch completed: \(results.count)/\(paths.count) files in \(String(format: "%.2f", elapsed))s", category: .network)

        return results
    }

    // MARK: - Search

    func simpleSearch(_ query: String) async throws -> [ObsidianSearchResult] {
        // Build URL with query parameter
        var components = URLComponents(string: "\(baseURL)/search/simple/")
        components?.queryItems = [
            URLQueryItem(name: "query", value: query),
            URLQueryItem(name: "contextLength", value: "100")
        ]

        guard let url = components?.url else {
            throw ObsidianError.invalidURL
        }

        Log.debug("Searching vault for: \(query)", category: .network)

        return try await Retry.execute(policy: retryPolicy, operation: "simpleSearch") {
            var request = URLRequest(url: url)
            request.httpMethod = "POST"
            request.setValue("Bearer \(self.apiKey)", forHTTPHeaderField: "Authorization")
            request.setValue("application/json", forHTTPHeaderField: "Accept")

            let (data, response) = try await self.quickSession.data(for: request)

            guard let httpResponse = response as? HTTPURLResponse else {
                throw ObsidianError.invalidResponse
            }

            guard httpResponse.statusCode == 200 else {
                throw ObsidianError.httpError(httpResponse.statusCode)
            }

            // Try to decode the search results
            do {
                let results = try JSONDecoder().decode([ObsidianSearchResult].self, from: data)
                Log.info("Search returned \(results.count) results for: \(query)", category: .network)
                return results
            } catch {
                // Log the raw response for debugging
                if let jsonString = String(data: data, encoding: .utf8) {
                    Log.error("Search JSON decode failed. Response preview: \(String(jsonString.prefix(500)))", category: .network)
                }
                throw error
            }
        }
    }

    // MARK: - Recent Changes

    func getRecentChanges(limit: Int = 20) async throws -> [ObsidianFile] {
        guard let url = URL(string: "\(baseURL)/periodic/daily/") else {
            // Fallback: return all files sorted by modification
            let allFiles = try await listFilesInVault()
            return Array(allFiles.prefix(limit))
        }

        do {
            return try await Retry.execute(policy: .quick, operation: "getRecentChanges") {
                var request = URLRequest(url: url)
                request.setValue("Bearer \(self.apiKey)", forHTTPHeaderField: "Authorization")
                request.setValue("application/json", forHTTPHeaderField: "Accept")

                let (data, response) = try await self.quickSession.data(for: request)

                guard let httpResponse = response as? HTTPURLResponse,
                      httpResponse.statusCode == 200 else {
                    throw ObsidianError.invalidResponse
                }

                let recentResponse = try JSONDecoder().decode(RecentChangesResponse.self, from: data)

                return recentResponse.files.map { file in
                    ObsidianFile(path: file.path, lastModified: file.modified)
                }
            }
        } catch {
            // Fallback to listing all files on error
            Log.warning("getRecentChanges failed, using fallback: \(error.localizedDescription)", category: .network)
            let allFiles = try await listFilesInVault()
            return Array(allFiles.filter { $0.path.hasSuffix(".md") }.prefix(limit))
        }
    }

    // MARK: - Write/Append Content

    func appendContent(to path: String, content: String) async throws {
        let encodedPath = path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? path
        guard let url = URL(string: "\(baseURL)/vault/\(encodedPath)") else {
            throw ObsidianError.invalidURL
        }

        Log.debug("Appending content to: \(path)", category: .network)

        try await Retry.execute(policy: retryPolicy, operation: "appendContent") {
            var request = URLRequest(url: url)
            request.httpMethod = "POST"
            request.setValue("Bearer \(self.apiKey)", forHTTPHeaderField: "Authorization")
            request.setValue("text/markdown", forHTTPHeaderField: "Content-Type")
            request.httpBody = content.data(using: .utf8)

            let (_, response) = try await self.quickSession.data(for: request)

            guard let httpResponse = response as? HTTPURLResponse else {
                throw ObsidianError.invalidResponse
            }

            guard httpResponse.statusCode == 200 || httpResponse.statusCode == 204 else {
                throw ObsidianError.httpError(httpResponse.statusCode)
            }

            Log.info("Content appended to: \(path)", category: .network)
        }
    }

    func createFile(at path: String, content: String) async throws {
        let encodedPath = path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? path
        guard let url = URL(string: "\(baseURL)/vault/\(encodedPath)") else {
            throw ObsidianError.invalidURL
        }

        Log.debug("Creating file at: \(path)", category: .network)

        try await Retry.execute(policy: retryPolicy, operation: "createFile") {
            var request = URLRequest(url: url)
            request.httpMethod = "PUT"
            request.setValue("Bearer \(self.apiKey)", forHTTPHeaderField: "Authorization")
            request.setValue("text/markdown", forHTTPHeaderField: "Content-Type")
            request.httpBody = content.data(using: .utf8)

            let (_, response) = try await self.quickSession.data(for: request)

            guard let httpResponse = response as? HTTPURLResponse else {
                throw ObsidianError.invalidResponse
            }

            guard httpResponse.statusCode == 200 || httpResponse.statusCode == 204 else {
                throw ObsidianError.httpError(httpResponse.statusCode)
            }

            Log.info("File created at: \(path)", category: .network)
        }
    }
}

// MARK: - Errors

enum ObsidianError: LocalizedError {
    case invalidURL
    case invalidResponse
    case httpError(Int)
    case notConnected
    case decodingError

    var errorDescription: String? {
        switch self {
        case .invalidURL:
            return "Invalid URL"
        case .invalidResponse:
            return "Invalid response from server"
        case .httpError(let code):
            return "HTTP error: \(code)"
        case .notConnected:
            return "Not connected to Obsidian"
        case .decodingError:
            return "Failed to decode response"
        }
    }
}


     1→import Foundation
     2→
     3→// MARK: - Obsidian File Models
     4→
     5→struct ObsidianFile: Identifiable, Codable, Hashable {
     6→    let id: String
     7→    let path: String
     8→    let name: String
     9→    let isDirectory: Bool
    10→    var content: String?
    11→    var lastModified: Date?
    12→
    13→    init(path: String, isDirectory: Bool = false, content: String? = nil, lastModified: Date? = nil) {
    14→        self.id = path
    15→        self.path = path
    16→        self.name = URL(fileURLWithPath: path).lastPathComponent
    17→        self.isDirectory = isDirectory
    18→        self.content = content
    19→        self.lastModified = lastModified
    20→    }
    21→
    22→    var displayName: String {
    23→        name.replacingOccurrences(of: ".md", with: "")
    24→    }
    25→
    26→    var icon: String {
    27→        isDirectory ? "folder.fill" : "doc.text.fill"
    28→    }
    29→}
    30→
    31→struct ObsidianSearchResult: Identifiable, Codable {
    32→    // Use filename as id to avoid UUID generation during decoding
    33→    var id: String { filename }
    34→    let filename: String
    35→    let matches: [SearchMatch]
    36→    let score: Double?
    37→
    38→    struct SearchMatch: Codable, Identifiable {
    39→        // Generate a stable id from context
    40→        var id: String { "\(match.start ?? 0)-\(match.end ?? 0)" }
    41→        let match: MatchContent
    42→        let context: String?
    43→
    44→        struct MatchContent: Codable {
    45→            let start: Int?
    46→            let end: Int?
    47→
    48→            // Handle both string and object formats
    49→            init(from decoder: Decoder) throws {
    50→                if let container = try? decoder.singleValueContainer(),
    51→                   let _ = try? container.decode(String.self) {
    52→                    // String format - no position info
    53→                    self.start = nil
    54→                    self.end = nil
    55→                } else {
    56→                    let container = try decoder.container(keyedBy: CodingKeys.self)
    57→                    self.start = try container.decodeIfPresent(Int.self, forKey: .start)
    58→                    self.end = try container.decodeIfPresent(Int.self, forKey: .end)
    59→                }
    60→            }
    61→
    62→            enum CodingKeys: String, CodingKey {
    63→                case start, end
    64→            }
    65→        }
    66→
    67→        enum CodingKeys: String, CodingKey {
    68→            case match, context
    69→        }
    70→
    71→        var matchText: String {
    72→            context ?? ""
    73→        }
    74→    }
    75→
    76→    enum CodingKeys: String, CodingKey {
    77→        case filename, matches, score
    78→    }
    79→}
    80→
    81→// MARK: - API Response Models
    82→
    83→struct VaultFilesResponse: Codable {
    84→    let files: [String]
    85→}
    86→
    87→struct FileContentResponse: Codable {
    88→    let content: String
    89→}
    90→
    91→struct RecentChangesResponse: Codable {
    92→    let files: [RecentFile]
    93→
    94→    struct RecentFile: Codable {
    95→        let path: String
    96→        let modified: Date?
    97→    }
    98→}
    99→
   100→// MARK: - Vault Statistics
   101→
   102→struct VaultStats: Codable {
   103→    var totalFiles: Int
   104→    var markdownFiles: Int
   105→    var folders: Int
   106→    var lastUpdated: Date
   107→}
   108→
   109→// MARK: - Chat/AI Models
   110→
   111→struct ChatMessage: Identifiable, Equatable {
   112→    let id: UUID
   113→    let role: Role
   114→    var content: String  // Mutable for streaming updates
   115→    let timestamp: Date
   116→
   117→    enum Role: String, Codable {
   118→        case user
   119→        case assistant
   120→        case system
   121→    }
   122→
   123→    init(id: UUID = UUID(), role: Role, content: String, timestamp: Date = Date()) {
   124→        self.id = id
   125→        self.role = role
   126→        self.content = content
   127→        self.timestamp = timestamp
   128→    }
   129→}
   130→
   131→struct LMGatewayRequest: Codable {
   132→    let model: String
   133→    let messages: [Message]
   134→    let temperature: Double
   135→    let max_tokens: Int
   136→    let stream: Bool
   137→
   138→    struct Message: Codable {
   139→        let role: String
   140→        let content: String
   141→    }
   142→}
   143→
   144→struct LMGatewayResponse: Codable {
   145→    let id: String?
   146→    let choices: [Choice]
   147→    let usage: Usage?
   148→
   149→    struct Choice: Codable {
   150→        let message: Message
   151→        let finish_reason: String?
   152→
   153→        struct Message: Codable {
   154→            let role: String
   155→            let content: String
   156→        }
   157→    }
   158→
   159→    struct Usage: Codable {
   160→        let prompt_tokens: Int
   161→        let completion_tokens: Int
   162→        let total_tokens: Int
   163→    }
   164→}
   165→
   166→struct LMModel: Identifiable, Codable {
   167→    let id: String
   168→    let object: String?
   169→    let owned_by: String?
   170→}
   171→
   172→struct LMModelsResponse: Codable {
   173→    let data: [LMModel]
   174→}
   175→
   176→// MARK: - App State
   177→
   178→enum LoadingState<T> {
   179→    case idle
   180→    case loading
   181→    case loaded(T)
   182→    case error(String)
   183→
   184→    var isLoading: Bool {
   185→        if case .loading = self { return true }
   186→        return false
   187→    }
   188→}
   189→
   190→// MARK: - Settings
   191→
   192→struct AppSettings: Codable {
   193→    var obsidianAPIUrl: String
   194→    var obsidianAPIKey: String
   195→    var lmGatewayUrl: String
   196→    var lmGatewayApiKey: String
   197→    var selectedModel: String
   198→    var theme: AppTheme
   199→
   200→    /// Default settings with empty credentials
   201→    /// Note: Credentials should be loaded from Keychain at runtime
   202→    static let `default` = AppSettings(
   203→        obsidianAPIUrl: "",
   204→        obsidianAPIKey: "",
   205→        lmGatewayUrl: "",
   206→        lmGatewayApiKey: "",
   207→        selectedModel: "qwen/qwen3-next-80b",
   208→        theme: .system
   209→    )
   210→
   211→    /// Create settings loaded from Keychain
   212→    static func fromKeychain() -> AppSettings {
   213→        AppSettings(
   214→            obsidianAPIUrl: Keychain.obsidianAPIUrl ?? "",
   215→            obsidianAPIKey: Keychain.obsidianAPIKey ?? "",
   216→            lmGatewayUrl: Keychain.lmGatewayUrl ?? "",
   217→            lmGatewayApiKey: Keychain.lmGatewayApiKey ?? "",
   218→            selectedModel: Keychain.selectedModel ?? "qwen/qwen3-next-80b",
   219→            theme: .system
   220→        )
   221→    }
   222→
   223→    enum AppTheme: String, Codable, CaseIterable {
   224→        case light, dark, system
   225→    }
   226→}
   227→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

import Foundation

// MARK: - Obsidian File Models

struct ObsidianFile: Identifiable, Codable, Hashable {
    let id: String
    let path: String
    let name: String
    let isDirectory: Bool
    var content: String?
    var lastModified: Date?

    init(path: String, isDirectory: Bool = false, content: String? = nil, lastModified: Date? = nil) {
        self.id = path
        self.path = path
        self.name = URL(fileURLWithPath: path).lastPathComponent
        self.isDirectory = isDirectory
        self.content = content
        self.lastModified = lastModified
    }

    var displayName: String {
        name.replacingOccurrences(of: ".md", with: "")
    }

    var icon: String {
        isDirectory ? "folder.fill" : "doc.text.fill"
    }
}

struct ObsidianSearchResult: Identifiable, Codable {
    // Use filename as id to avoid UUID generation during decoding
    var id: String { filename }
    let filename: String
    let matches: [SearchMatch]
    let score: Double?

    struct SearchMatch: Codable, Identifiable {
        // Generate a stable id from context
        var id: String { "\(match.start ?? 0)-\(match.end ?? 0)" }
        let match: MatchContent
        let context: String?

        struct MatchContent: Codable {
            let start: Int?
            let end: Int?

            // Handle both string and object formats
            init(from decoder: Decoder) throws {
                if let container = try? decoder.singleValueContainer(),
                   let _ = try? container.decode(String.self) {
                    // String format - no position info
                    self.start = nil
                    self.end = nil
                } else {
                    let container = try decoder.container(keyedBy: CodingKeys.self)
                    self.start = try container.decodeIfPresent(Int.self, forKey: .start)
                    self.end = try container.decodeIfPresent(Int.self, forKey: .end)
                }
            }

            enum CodingKeys: String, CodingKey {
                case start, end
            }
        }

        enum CodingKeys: String, CodingKey {
            case match, context
        }

        var matchText: String {
            context ?? ""
        }
    }

    enum CodingKeys: String, CodingKey {
        case filename, matches, score
    }
}

// MARK: - API Response Models

struct VaultFilesResponse: Codable {
    let files: [String]
}

struct FileContentResponse: Codable {
    let content: String
}

struct RecentChangesResponse: Codable {
    let files: [RecentFile]

    struct RecentFile: Codable {
        let path: String
        let modified: Date?
    }
}

// MARK: - Vault Statistics

struct VaultStats: Codable {
    var totalFiles: Int
    var markdownFiles: Int
    var folders: Int
    var lastUpdated: Date
}

// MARK: - Chat/AI Models

struct ChatMessage: Identifiable, Equatable {
    let id: UUID
    let role: Role
    var content: String  // Mutable for streaming updates
    let timestamp: Date

    enum Role: String, Codable {
        case user
        case assistant
        case system
    }

    init(id: UUID = UUID(), role: Role, content: String, timestamp: Date = Date()) {
        self.id = id
        self.role = role
        self.content = content
        self.timestamp = timestamp
    }
}

struct LMGatewayRequest: Codable {
    let model: String
    let messages: [Message]
    let temperature: Double
    let max_tokens: Int
    let stream: Bool

    struct Message: Codable {
        let role: String
        let content: String
    }
}

struct LMGatewayResponse: Codable {
    let id: String?
    let choices: [Choice]
    let usage: Usage?

    struct Choice: Codable {
        let message: Message
        let finish_reason: String?

        struct Message: Codable {
            let role: String
            let content: String
        }
    }

    struct Usage: Codable {
        let prompt_tokens: Int
        let completion_tokens: Int
        let total_tokens: Int
    }
}

struct LMModel: Identifiable, Codable {
    let id: String
    let object: String?
    let owned_by: String?
}

struct LMModelsResponse: Codable {
    let data: [LMModel]
}

// MARK: - App State

enum LoadingState<T> {
    case idle
    case loading
    case loaded(T)
    case error(String)

    var isLoading: Bool {
        if case .loading = self { return true }
        return false
    }
}

// MARK: - Settings

struct AppSettings: Codable {
    var obsidianAPIUrl: String
    var obsidianAPIKey: String
    var lmGatewayUrl: String
    var lmGatewayApiKey: String
    var selectedModel: String
    var theme: AppTheme

    /// Default settings with empty credentials
    /// Note: Credentials should be loaded from Keychain at runtime
    static let `default` = AppSettings(
        obsidianAPIUrl: "",
        obsidianAPIKey: "",
        lmGatewayUrl: "",
        lmGatewayApiKey: "",
        selectedModel: "qwen/qwen3-next-80b",
        theme: .system
    )

    /// Create settings loaded from Keychain
    static func fromKeychain() -> AppSettings {
        AppSettings(
            obsidianAPIUrl: Keychain.obsidianAPIUrl ?? "",
            obsidianAPIKey: Keychain.obsidianAPIKey ?? "",
            lmGatewayUrl: Keychain.lmGatewayUrl ?? "",
            lmGatewayApiKey: Keychain.lmGatewayApiKey ?? "",
            selectedModel: Keychain.selectedModel ?? "qwen/qwen3-next-80b",
            theme: .system
        )
    }

    enum AppTheme: String, Codable, CaseIterable {
        case light, dark, system
    }
}


Perfect! I now have all the necessary files. Let me examine them more closely and create a comprehensive analysis report.

## Search Functionality Analysis Report

I've identified **multiple critical issues** causing the "data couldn't be read because it isn't in the correct format" error. Here's the comprehensive breakdown:

---

### **ISSUE #1: Model Structure Mismatch - CRITICAL**

**Location:** `/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Models/ObsidianModels.swift` (lines 31-79)

**Problem:** The `ObsidianSearchResult` model does NOT match the actual API response structure.

**Expected API Response (from your curl test):**
```json
[
  {
    "filename": "path/to/file.md",
    "score": -659.539,
    "matches": [
      {
        "match": {
          "start": 29552,
          "end": 29558
        },
        "context": "...matched text with context..."
      }
    ]
  }
]
```

**Current Model Definition:**
```swift
struct ObsidianSearchResult: Identifiable, Codable {
    var id: String { filename }
    let filename: String
    let matches: [SearchMatch]
    let score: Double?

    struct SearchMatch: Codable, Identifiable {
        var id: String { "\(match.start ?? 0)-\(match.end ?? 0)" }
        let match: MatchContent
        let context: String?

        struct MatchContent: Codable {
            let start: Int?
            let end: Int?
            // ... custom init trying to handle both string and object formats
        }
    }
}
```

**The Critical Mismatch:**
- The model expects `matches[].match.start` and `matches[].match.end`
- But the API returns `matches[].match.start` and `matches[].match.end` directly under `match` object ✓ (This actually looks correct!)
- However, there's unnecessary complexity with the custom `init(from decoder:)` in `MatchContent` that tries to handle string format which doesn't exist in the API response.

---

### **ISSUE #2: Custom Decoding Logic is Broken**

**Location:** `ObsidianModels.swift` lines 48-60 in `MatchContent`

**Problem:** The custom `init(from decoder:)` is attempting to decode `match` as if it could be a String, but the API always returns it as an object:

```swift
struct MatchContent: Codable {
    let start: Int?
    let end: Int?

    init(from decoder: Decoder) throws {
        if let container = try? decoder.singleValueContainer(),
           let _ = try? container.decode(String.self) {
            // This branch tries to handle STRING format (which never happens!)
            self.start = nil
            self.end = nil
        } else {
            // Falls back to keyed container
            let container = try decoder.container(keyedBy: CodingKeys.self)
            self.start = try container.decodeIfPresent(Int.self, forKey: .start)
            self.end = try container.decodeIfPresent(Int.self, forKey: .end)
        }
    }
}
```

**Why This Fails:** The first `try?` silently catches all errors. If ANY decoder error occurs during the `singleValueContainer()` attempt, it proceeds to the `else` block. This can mask real decoding failures and produce unpredictable results.

---

### **ISSUE #3: Missing Error Context in Search Endpoint**

**Location:** `/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/ObsidianService.swift` (lines 371-414)

**Problem:** The error handling in `simpleSearch()` provides minimal debugging information:

```swift
func simpleSearch(_ query: String) async throws -> [ObsidianSearchResult] {
    // ... setup ...

    return try await Retry.execute(policy: retryPolicy, operation: "simpleSearch") {
        // ... request setup ...

        let (data, response) = try await self.quickSession.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw ObsidianError.invalidResponse
        }

        guard httpResponse.statusCode == 200 else {
            throw ObsidianError.httpError(httpResponse.statusCode)
        }

        do {
            let results = try JSONDecoder().decode([ObsidianSearchResult].self, from: data)
            Log.info("Search returned \(results.count) results for: \(query)", category: .network)
            return results
        } catch {
            // Log the raw response for debugging
            if let jsonString = String(data: data, encoding: .utf8) {
                Log.error("Search JSON decode failed. Response preview: \(String(jsonString.prefix(500)))", category: .network)
            }
            throw error  // Re-throws the JSONDecoder error
        }
    }
}
```

**The Problem:**
- The generic JSONDecoder error message gets propagated to SearchView
- SearchView displays this as `error.localizedDescription` (line 232 in SearchView.swift)
- JSONDecoder errors translate to: "The data couldn't be read because it isn't in the correct format"
- No information about WHICH field failed to decode

---

### **ISSUE #4: SearchView Error Handling is Too Generic**

**Location:** `/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Views/SearchView.swift` (lines 228-235)

```swift
Task {
    do {
        searchResults = try await obsidianService.simpleSearch(query)
    } catch {
        errorMessage = error.localizedDescription  // Very generic error display
    }
    isSearching = false
}
```

**The Problem:** Shows the raw JSONDecoder error message to the user with no context about what went wrong.

---

### **Root Cause Summary**

The "incorrect format" error occurs because:

1. **JSONDecoder throws a generic error** when it encounters a decoding mismatch
2. **The custom `init(from decoder:)` in MatchContent** has overly complex error handling that doesn't properly handle all failure cases
3. **The error gets caught and re-thrown without additional context**, so users see the cryptic JSONDecoder message
4. **SearchView displays the error as-is** without any helpful debugging information

---

### **How to Verify the Issue**

The actual API response structure you showed works with the current model definition in terms of field names, BUT:

1. Check if the API sometimes returns `match` as a null or empty object
2. Check if certain fields (start, end, context) are sometimes missing entirely
3. The custom decoder is trying to work around edge cases that may exist in some responses

---

### **Recommended Fixes**

1. **Simplify the MatchContent decoder** - remove the complex string-handling logic unless there's a documented API variant that returns strings
2. **Add detailed JSON validation** before decoding - print the raw JSON to identify what's actually being returned
3. **Improve error messages** - wrap JSONDecoder errors with context about which search result failed
4. **Add logging** in SearchView to show what was actually received from the API

The files to investigate for edge cases:
- `/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Models/ObsidianModels.swift` (lines 31-79)
- `/Users/neog/Apps/iOS/ObsidianViewer-iOS/ObsidianViewer-iOS/Services/ObsidianService.swift` (lines 401-414)